From 93677c26ecdf1473f552ec1c0fc80d2b18714cbc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 12:52:58 -0700 Subject: [PATCH 01/71] Shape Builder: a large selection merges on another thread A Shape Builder click or drag over a selection of more than 128 segments merged on the UI thread, so the app stopped drawing and answering until the merge was done. The tool now computes such a merge on another thread from the map it built for the hover. Meanwhile the canvas keeps drawing, the touched regions stay highlighted and the pointer is the busy pointer. When the merge is done, the tool runs shapeBuilder.merge with the gesture's params as one undo step, and the command takes the computed shapes when it would compute the same ones. The web build merges in the command, as before. A press while the map is still being built no longer waits for it on the UI thread: the tool records the gesture and makes it once the map is there. Clicks, drags and the Shape Builder's arrow keys made during the merge, and commands, menu items, the app's own dialogs, saves and tool or document switches, run after it on the merged art; engine queries that only read run at once. A change to the selected paths made outside a command drops the merge. The headless host and ui.pointer reply once the merge is applied. The Shape Builder and the Live Paint Bucket keep their map of the selection over a new revision of the document when the selected paths and their outlines are unchanged, instead of building it again. --- .../engine/src/cmd/buildcmds/shape_builder.rs | 69 ++-- crates/engine/src/cmd/fileio/save.rs | 2 + crates/engine/src/cmd/prefscmds.rs | 6 + crates/engine/src/command_sets.rs | 27 ++ crates/engine/src/lib.rs | 15 +- crates/engine/src/session/documents.rs | 8 + crates/engine/src/session/edit.rs | 5 + crates/engine/src/session/execute.rs | 6 + crates/engine/src/session/undo.rs | 4 + crates/engine/src/tests/build.rs | 385 ++++++++++++++++++ crates/engine/src/tooling.rs | 250 +++++++++++- crates/engine/tests/command_sweep.rs | 46 +++ crates/mcp/src/headless.rs | 11 +- crates/mcp/src/tests.rs | 9 + crates/tools/src/builder.rs | 275 ++++++++++++- crates/tools/src/builder/cache.rs | 88 +++- crates/tools/src/builder/live_paint.rs | 5 +- crates/tools/src/builder/lookup.rs | 2 + crates/tools/src/builder/map.rs | 114 +++++- crates/tools/src/builder/tests.rs | 251 +++++++++++- crates/tools/src/lib.rs | 32 +- crates/ui-egui/src/app.rs | 9 +- crates/ui-egui/src/app/commands.rs | 8 + crates/ui-egui/src/app/control.rs | 32 ++ crates/ui-egui/src/canvas/drag.rs | 33 +- crates/ui-egui/src/canvas/input.rs | 10 +- crates/ui-egui/src/control.rs | 26 +- crates/ui-egui/src/control/tests.rs | 237 +++++++++++ crates/ui-egui/src/io/export.rs | 3 + crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/menus/invoke.rs | 5 + crates/ui-egui/src/menus/run.rs | 25 +- crates/ui-egui/src/placing.rs | 4 + crates/ui-egui/src/placing/tests.rs | 33 ++ crates/ui-egui/src/unsaved.rs | 14 +- docs/control-protocol.md | 2 +- docs/mcp.md | 2 +- 37 files changed, 1910 insertions(+), 146 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds/shape_builder.rs b/crates/engine/src/cmd/buildcmds/shape_builder.rs index 07d4949a0..62ce7a40d 100644 --- a/crates/engine/src/cmd/buildcmds/shape_builder.rs +++ b/crates/engine/src/cmd/buildcmds/shape_builder.rs @@ -37,6 +37,7 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result { let gap = gap_param(p); // The map the Shape Builder tool already has, which a click or drag of it is made over. let shared = s.tool.shape_builder_map().filter(|(_, g)| *g == gap); + let prepared = s.tool.shape_builder_merge(); let label = if erase { "Shape Builder Delete" } else { "Shape Builder" }; let (ids, merged, touched, split) = s.edit(label, |d, sel| { let (shapes, leaves) = b::shape_builder_shapes(d, &roots, open_filled_as_closed); @@ -85,16 +86,28 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result { touched.add(map.hit(q, Some(tol))); return Ok((ids, None, touched, Some(split))); } - let mut touched = Touched { regions: marquee.map(|(a, b)| map.regions_in_marquee(a, b)).unwrap_or_default(), ..Default::default() }; - touched.extend(&map.builder_hits_along(&pts, piece_tol)); - touched.edges = map.erased_edges(&touched); + let touched = map.merge_touched(&pts, marquee, piece_tol); if touched.is_empty() { return Err(EngineError::Other("Shape Builder: no region under the pointer".into())); } - let cuts: Vec<&SubPath> = touched.edges.iter().filter_map(|&k| map.edges.get(k)?.0.path.subpaths.first()).collect(); + let inputs = b::merge_inputs(&leaves.iter().collect::>(), open_filled_as_closed); + // The tool computed this merge on another thread when it would come out the same. + let ready = prepared.as_ref().filter(|m| { + shared.as_ref().is_some_and(|(s, _)| Arc::ptr_eq(s, &m.map) && std::ptr::eq(s.as_ref(), map)) + && m.gap == gap + && m.touched == touched + && m.inputs == inputs + }); + let computed; + let geometry = match ready { + Some(m) => &m.geometry, + None => { + computed = b::merge_geometry(map, &touched, gap, &inputs); + &computed + } + }; let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect(); - let faces = po::FaceMerger::with_gap(&map.shapes, gap_param(p)); - let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) }; + let union = &geometry.union; // Pick Color From Artwork: the merged shape takes the look of the front-most object under the // drag's first point, else under its last point, else of the front-most object. Color // Swatches: the current fill, with the rest of the look of the front-most object over the @@ -110,7 +123,7 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result { let style_src = covered.or(map.shapes.len().checked_sub(1)); let mut new_nodes: Vec = vec![]; let mut merged_id = None; - for (i, (sh, leaf)) in map.shapes.iter().zip(&leaves).enumerate() { + for (i, leaf) in leaves.iter().take(map.shapes.len()).enumerate() { let pieces: Vec = map.lines.iter().enumerate().filter(|(_, (l, _))| l.key == i as u64).map(|(k, _)| k).collect(); if !pieces.is_empty() { // An open path loses the pieces deleted and those between two merged (or deleted) regions. @@ -125,43 +138,15 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result { } } } - } else { + } else if let Some(Some(rest)) = geometry.rests.get(i) { // A shape loses the regions deleted or merged, then the edges deleted (each one on - // its outline, shared edges from every shape along them). - let in_union = picked.iter().any(|r| r.sources.contains(&i)); - let mut rest = if in_union { - // What is left of it: its faces not merged or deleted, united. They share their - // edges with the merged faces exactly, where subtracting the merged shape left - // slivers of the outline along them (#569). A sliver goes in only where the - // shape covers it and the merged shape does not take it in. - let kept: Vec<&po::Region> = map - .regions - .iter() - .enumerate() - .filter(|(k, (r, ..))| r.sources.contains(&i) && !touched.regions.contains(k)) - .map(|(_, (r, ..))| r) - .collect(); - if kept.is_empty() { PathData::default() } else { faces.merge_rest(i, &kept, &picked) } - } else { - b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0) - }; - let fill_closes = open_filled_as_closed && !leaf.appearance.fill_paint().is_none(); - // Edges come back from the planar map within a few hundredths of the outlines they - // follow, whatever else is selected. - let cut_tol = b::outline_snap(&sh.path); - let mut cut = false; - for piece in &cuts { - if let Some(r) = po::cut_out(&rest, piece, fill_closes, cut_tol) { - rest = r; - cut = true; - } - } - if !in_union && !cut { - new_nodes.push(d.reid(leaf)); - } else if !rest.is_empty() { - // What is left of its own outline keeps its fill rule; united faces fill with non-zero. - new_nodes.push(remnant(d, leaf, rest, in_union.then_some(FillRule::NonZero))); + // its outline, shared edges from every shape along them). What is left of its own + // outline keeps its fill rule; united faces fill with non-zero. + if !rest.path.is_empty() { + new_nodes.push(remnant(d, leaf, rest.path.clone(), rest.united.then_some(FillRule::NonZero))); } + } else { + new_nodes.push(d.reid(leaf)); } if !erase && !union.is_empty() && Some(i) == style_src { let mut m = shape_node(d, union.clone(), Some(leaf)); diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 486777a24..a0f92040c 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -631,6 +631,8 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res /// file becomes the document's own; the live document gets them in [`SaveJob::finish`]). Bad /// options fail here, before anything is encoded. pub fn save_job(s: &mut Session, plan: SavePlan) -> Result { + // A Shape Builder merge still being computed for a click is saved with the document. + s.finish_tool_edit_later()?; // The web build, without a clock, leaves the dates unless given one. let at = if plan.path.is_some() && plan.retargets() { clock_date(s, "modified", plan.modified) } else { None }; job_for_stamped(s, s.doc()?, plan, at) diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index bb7fce7c6..dda1bc911 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -550,6 +550,12 @@ impl Session { for e in self.prefs.keep_folders(&mut p) { log::warn!("{e}"); } + // The edit the active tool computes on another thread is applied first (the Preferences + // dialog applies its settings here, outside a command). A failure to apply it was the + // edit's own, and the preferences apply all the same. + if self.depth == 0 { + let _ = self.finish_tool_edit_later(); + } let grid_changed = p.gridline_every != self.prefs.gridline_every || p.grid_subdivisions != self.prefs.grid_subdivisions; let history_changed = p.history_states != self.prefs.history_states; let tile_edge_changed = p.pattern_tile_edge_color != self.prefs.pattern_tile_edge_color; diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index 8f3f35528..0188f222c 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -63,6 +63,33 @@ pub(crate) fn strip_replay_only(id: &str, p: &mut Value) { } } +/// Queries that apply the edit the active tool is computing before they run, as edits do +/// ([`Session::finish_tool_edit`]): a batch runs edits, Select All Instances and Lock Guides +/// change the selection, and the proof views, the preferences and added fonts give the documents +/// new revisions (they draw again, and type lays out again). +const WAIT_FOR_TOOL_EDIT: &[&str] = &[ + "command.batch", + "symbol.selectInstances", + "view.guides.lock", + "view.proofSetup", + "view.proofColors", + "view.overprintPreview", + "view.separationsPreview", + "prefs.set", + "prefs.reset", + "text.addFontFiles", +]; + +/// Does command `id`, run on its own, first apply the edit the active tool is computing on +/// another thread and the input held for it ([`Session::finish_tool_edit`])? Every command that +/// can edit, select, save or give a document a new revision does; queries that only read do not +/// (`commands_that_do_not_wait_for_a_tool_edit_change_no_document` in the command sweep checks +/// them). A frontend that adds params from +/// its own state (the active artboard) applies them first itself. +pub fn waits_for_tool_edit(id: &str) -> bool { + find_command(id).is_some_and(|spec| spec.journal || WAIT_FOR_TOOL_EDIT.contains(&id)) +} + /// Commands that act on the selection and take `ids` by selecting them first (`select.set`, with /// its checks), then running as on the selection: the result and the selection afterwards are the /// menu command's (#2130). diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ebae936f8..102d76cb0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -60,6 +60,19 @@ 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>, + /// Pointer and key input that came while the tool computed an edit on another thread, each with + /// its number and view, made in order once the edit is applied. + held_input: std::collections::VecDeque<(u64, tooling::HeldInput, ViewInfo)>, + /// The number the next pointer or key event handed to the session gets. + input_count: u64, + /// The number of the event whose handling started the edit the tool is computing. + edit_started_by: Option, + /// Set while the tool's edit and the input held for it are applied: the commands that run then + /// do not start applying them again. + settling_tool_edit: bool, + /// Requests for the UI from making held input where nothing could take them (a command, a tool + /// switch), which the next [`Session::tool_poll`] returns. + held_ui: Vec, /// 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>, @@ -249,7 +262,7 @@ mod session; pub(crate) use command_sets::SELECTS_GIVEN_IDS; #[cfg(test)] use command_sets::given_ids; -pub use command_sets::{FIT_ACTIVE_ARTBOARD, ON_ACTIVE_ARTBOARD}; +pub use command_sets::{FIT_ACTIVE_ARTBOARD, ON_ACTIVE_ARTBOARD, waits_for_tool_edit}; pub use doc_state::{DocState, OpenRows, RecalledSelection}; pub use error::{EngineError, Result}; pub use history::{History, HistoryEntry, NamedStep, default_history_budget}; diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index b0bf0f792..19a5d0be0 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -28,6 +28,11 @@ impl Session { journal: Journal::default(), tool: vectorcraft_tools::create("selection"), lender: None, + held_input: Default::default(), + input_count: 0, + edit_started_by: None, + settling_tool_edit: false, + held_ui: vec![], last_selection_tool: None, last_view: ViewInfo::default(), preview_memo: Default::default(), @@ -124,6 +129,9 @@ impl Session { return; } let view = self.last_view; + // The edit the tool computes on another thread and the input held for it belong to the + // document being left. The switch goes on whatever they did, as for the deactivation. + let _ = self.finish_tool_edit_later(); // The switch goes on whatever the lent tool's last actions did, as for the deactivation. let _ = self.give_back_tool(view); let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx)); diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index 6e9267720..c6a96c269 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -172,6 +172,11 @@ impl Session { if label != "Typing" && cmd::typing_in_progress(self) { cmd::settle_interaction(self)?; } + // An edit the tool computes on another thread (a Shape Builder merge) is applied first too, + // so the snapshot the interaction previews from and goes back to holds it. + if !self.in_interaction() { + self.finish_tool_edit_later()?; + } self.clear_deferred_overlay_cache(); let st = self.doc_mut()?; if st.interaction.is_some() { diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 45ede81e5..c4fd9ca8a 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -33,6 +33,12 @@ impl Session { /// Execute a command by id. This is THE entry point for every frontend. pub fn execute(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| cmd::params::unknown_command(id))?; + // An edit the active tool is computing on another thread (a Shape Builder merge) is + // applied before a command that may edit, select, save or give a document a new revision, + // as the click came first. Queries that only read do not wait for it. + if self.depth == 0 && waits_for_tool_edit(id) { + self.finish_tool_edit_later()?; + } // The crate's own tests are the audit of the params descriptions (#1442): a key a test // passes that its command's description doesn't name fails the test. #[cfg(test)] diff --git a/crates/engine/src/session/undo.rs b/crates/engine/src/session/undo.rs index f9f06135f..73c213e35 100644 --- a/crates/engine/src/session/undo.rs +++ b/crates/engine/src/session/undo.rs @@ -190,6 +190,10 @@ impl Session { /// [`Session::end_undo_scope`]: open an undo group there or, when one is open already, join it, /// so that the edits are part of its step. `None` without a document. pub fn begin_undo_scope(&mut self) -> Option { + // An edit the tool computes on another thread is a step before the scope, so the scope's + // snapshot holds it. A failure to apply it was the edit's own, and the scope begins all the + // same. + let _ = self.finish_tool_edit_later(); let journal = self.journal.len(); let st = self.active()?; let uid = st.uid; diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index a3f841b4a..de7862a13 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -528,6 +528,391 @@ fn shape_builder_merge_uses_the_map_the_tool_has() { assert_eq!(maps_built(), built + 1, "another gap"); } +/// 40 squares 20 pt wide in a row, each 10 pt right of the one before (160 segments, which the +/// Shape Builder merges on another thread), all selected. +fn square_row(s: &mut Session) -> Vec { + let ids: Vec = (0..40).map(|k| rect(s, f64::from(k) * 10.0, 0.0, 20.0, 20.0)).collect(); + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); + ids +} + +/// The active document of `s` without its dates, so documents made in two sessions at different +/// times compare equal when their art is. +fn undated(s: &Session) -> vectorcraft_doc::Document { + let mut d = (*s.doc().unwrap().doc).clone(); + (d.metadata.created, d.metadata.modified) = (None, None); + d +} + +/// The Shape Builder over [`square_row`], its map built, after a click at (15, 10): the merge is +/// being computed on another thread, and the document is as it was. +fn click_row(s: &mut Session, v: ViewInfo) { + s.select_tool("shapeBuilder", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Move, 15.0, 10.0), v).unwrap(); + s.tool_poll(v, true).unwrap(); + let revision = s.doc().unwrap().revision; + s.pointer(&PointerEvent::new(PointerKind::Down, 15.0, 10.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 15.0, 10.0), v).unwrap(); + assert!(s.tool_editing() && s.tool_waiting()); + assert_eq!(s.doc().unwrap().revision, revision, "the release returns before the merge is applied"); +} + +/// A click of the Shape Builder on a large selection merges on another thread, and the merge the +/// next poll applies is the command's own, as one undo step. +#[test] +fn shape_builder_merges_a_large_selection_on_another_thread() { + use vectorcraft_tools::builder::merges_computed; + let v = ViewInfo::default(); + let mut s = session(); + square_row(&mut s); + click_row(&mut s, v); + let computed = merges_computed(); + s.tool_poll(v, true).unwrap(); + assert!(!s.tool_editing()); + assert_eq!(merges_computed(), computed, "the command takes the merge computed on another thread"); + assert_eq!(s.journal.last().unwrap().0, "shapeBuilder.merge"); + assert_eq!(layer_children(&s), 41, "the overlap and what is left of the two squares over it"); + let mut direct = session(); + square_row(&mut direct); + direct.execute("shapeBuilder.merge", &json!({"points": [[15, 10]]})).unwrap(); + assert!(undated(&s) == undated(&direct), "the same document as the command gives"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(layer_children(&s), 40, "one undo step"); +} + +/// What comes after a click whose merge is being computed on another thread waits for the merge +/// and runs on the merged art, as when the merge ran on the calling thread: a command (an edit, +/// an undo, a selection, a save, a batch, Lock Guides, the proof views, a preference, Add Font +/// Files), an option change, another tool, a save made outside a command, preferences applied +/// outside a command, a dialog's preview, a scrubbed field's undo scope, a Cmd-click or Cmd-drag +/// with the selection tool. The merge is the one computed on the other thread. +#[test] +fn shape_builder_applies_a_merge_before_what_comes_next() { + use vectorcraft_tools::builder::merges_computed; + type Next = (&'static str, fn(&mut Session, &[NodeId], &std::path::Path)); + let v = ViewInfo::default(); + let next: Vec = vec![ + ("Select All Instances", |s, _, _| { + s.execute("symbol.selectInstances", &json!({"name": "S"})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects.len(), 1); + }), + ("an edit", |s, _, _| { + s.execute("object.move", &json!({"dx": 0, "dy": 100})).unwrap(); + }), + ("an undo", |s, _, _| { + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(layer_children(s), 40, "the undo takes the merge back"); + s.execute("edit.redo", &json!({})).unwrap(); + }), + ("another selection", |s, _, _| { + s.execute("select.inverse", &json!({})).unwrap(); + }), + ("Select All", |s, _, _| { + s.execute("select.all", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects.len(), 41); + }), + ("a save", |s, _, dir| { + s.execute("file.saveAs", &json!({"path": dir.join("a.vectorcraft").to_string_lossy()})).unwrap(); + assert!(!s.doc().unwrap().is_dirty(), "the file holds the merge"); + }), + ("a save outside a command", |s, _, dir| { + let p = json!({"path": dir.join("b.vectorcraft").to_string_lossy()}); + let plan = cmd::fileio::save_plan(s, cmd::fileio::SaveMode::SaveAs, &p).unwrap(); + assert_eq!(layer_children(s), 40, "planning a save leaves the document"); + let job = cmd::fileio::save_job(s, plan).unwrap(); + assert_eq!(layer_children(s), 41, "the snapshot is taken after the merge"); + drop(job); + }), + ("a batch", |s, _, _| { + s.execute("command.batch", &json!({"commands": [{"command": "object.move", "params": {"dx": 0, "dy": 50}}]})).unwrap(); + }), + ("an option change", |s, _, _| { + s.set_tool_option_cmd(&json!({"key": "gapDetection", "value": true})).unwrap(); + }), + ("another tool", |s, _, _| { + s.select_tool("selection", ViewInfo::default()).unwrap(); + }), + ("a closed document", |s, _, _| { + s.execute("file.close", &json!({})).unwrap(); + }), + ("Lock Guides", |s, _, _| { + s.execute("view.guides.lock", &json!({"locked": true})).unwrap(); + }), + ("an option set from a panel", |s, _, _| { + s.set_tool_option("gapDetection", &json!(true)); + }), + ("a dialog's preview, canceled", |s, _, _| { + s.begin_interaction("Move").unwrap(); + s.preview("object.move", &json!({"dx": 7, "dy": 0})).unwrap(); + s.preview("object.move", &json!({"dx": 9, "dy": 0})).unwrap(); + s.cancel_interaction().unwrap(); + assert_eq!(layer_children(s), 41, "canceling the preview keeps the merge"); + }), + ("a dialog's preview, kept", |s, _, _| { + s.begin_interaction("Move").unwrap(); + s.preview("object.move", &json!({"dx": 7, "dy": 0})).unwrap(); + s.commit_interaction().unwrap(); + let labels: Vec<&str> = s.doc().unwrap().history.undo.iter().rev().take(2).map(|e| e.label.as_str()).collect(); + assert_eq!(labels, ["Move", "Shape Builder"]); + }), + ("a scrubbed field, canceled", |s, _, _| { + let scope = s.begin_undo_scope().unwrap(); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + s.end_undo_scope(scope, true); + assert_eq!( + s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), + Some("Shape Builder"), + "the scrub's step is undone, not the merge" + ); + }), + ("a held click, then another tool", |s, _, _| { + let v = ViewInfo::default(); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, 55.0, 10.0), v).unwrap(); + } + assert!(s.tool_editing(), "the click is held"); + s.select_tool("selection", v).unwrap(); + let labels: Vec<&str> = s.doc().unwrap().history.undo.iter().rev().take(2).map(|e| e.label.as_str()).collect(); + assert_eq!(labels, ["Shape Builder", "Shape Builder"], "the held click merges with the Shape Builder first"); + }), + ("a held click, then another document", |s, _, _| { + let v = ViewInfo::default(); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, 55.0, 10.0), v).unwrap(); + } + let first = s.active_index().unwrap(); + s.add_document(vectorcraft_doc::Document::new(100.0, 100.0), None); + assert!(!s.tool_editing()); + let labels: Vec<&str> = s.documents()[first].history.undo.iter().rev().take(2).map(|e| e.label.as_str()).collect(); + assert_eq!(labels, ["Shape Builder", "Shape Builder"], "the held click merges in its own document first"); + s.set_active(first); + }), + ("a Cmd-click", |s, _, _| { + let v = ViewInfo::default(); + let cmd = Mods { cmd: true, ..Default::default() }; + assert_eq!(s.cursor(Point::new(500.0, 300.0), cmd, v), vectorcraft_tools::Cursor::Busy, "Cmd shows the merge is computed"); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, 500.0, 300.0).with_mods(cmd), v).unwrap(); + } + assert!(s.tool_editing(), "the click is held"); + while s.tool_waiting() { + s.tool_poll(v, true).unwrap(); + } + assert!(s.doc().unwrap().selection.is_empty(), "the lent selection tool deselects after the merge"); + assert_eq!(s.tool_id(), "shapeBuilder"); + }), + ("a Cmd-drag", |s, _, _| { + let v = ViewInfo::default(); + let cmd = Mods { cmd: true, ..Default::default() }; + for (kind, y) in [(PointerKind::Down, 10.0), (PointerKind::Drag, 60.0), (PointerKind::Drag, 110.0), (PointerKind::Up, 110.0)] { + s.pointer(&PointerEvent::new(kind, 305.0, y).with_mods(cmd), v).unwrap(); + } + assert!(s.tool_editing(), "the drag is held"); + while s.tool_waiting() { + s.tool_poll(v, true).unwrap(); + } + let labels: Vec<&str> = s.doc().unwrap().history.undo.iter().rev().take(2).map(|e| e.label.as_str()).collect(); + assert_eq!(labels, ["Move", "Shape Builder"]); + }), + // The proof views redraw every document (a new revision); they are left off, as the + // other tests of the process-wide proof view expect. + ("Separations Preview", |s, _, _| { + s.execute("view.separationsPreview", &json!({"on": false})).unwrap(); + }), + ("Overprint Preview", |s, _, _| { + s.execute("view.overprintPreview", &json!({"on": false})).unwrap(); + }), + ("Proof Colors", |s, _, _| { + s.execute("view.proofColors", &json!({"on": false})).unwrap(); + }), + ("Proof Setup", |s, _, _| { + s.execute("view.proofSetup", &json!({})).unwrap(); + }), + ("a preference", |s, _, _| { + let every = s.prefs.gridline_every; + s.execute("prefs.set", &json!({"key": "gridlineEvery", "value": every + 10.0})).unwrap(); + }), + ("Reset Preferences", |s, _, _| { + s.execute("prefs.reset", &json!({})).unwrap(); + }), + ("preferences applied outside a command", |s, _, _| { + let mut p = s.prefs.clone(); + p.gridline_every += 10.0; + s.apply_prefs(p); + }), + ("Add Font Files", |s, _, _| { + // No files: the command fails after the merge. + assert!(s.execute("text.addFontFiles", &json!({"files": []})).is_err()); + }), + ]; + let _proof = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let dir = vectorcraft_testkit::temp_dir("shape-builder-merge-first"); + for (what, then) in next { + let mut s = session(); + if what == "Select All Instances" { + let far = rect(&mut s, 500.0, 500.0, 20.0, 20.0); + s.execute("symbol.new", &json!({"name": "S", "ids": [far.0]})).unwrap(); + } + let ids = square_row(&mut s); + click_row(&mut s, v); + let computed = merges_computed(); + let journal = s.journal.len(); + then(&mut s, &ids, &dir); + assert!(!s.tool_editing(), "{what}"); + assert_eq!(merges_computed(), computed, "{what}: the merge computed on another thread"); + assert_eq!(s.journal.since(journal).first().map(|j| j.0.as_str()), Some("shapeBuilder.merge"), "{what}: the merge comes first"); + if what != "a closed document" { + assert!(layer_children(&s) >= 41, "{what}: the merge stays"); + } + } +} + +/// Gestures and arrow keys that come while a merge is computed on another thread are held and +/// made after it, in order: the document is the one they make one after the other, and the +/// events count as made once they are and the merge they start is applied. +#[test] +fn shape_builder_holds_gestures_made_during_a_merge() { + type Gesture = fn(&mut Session, ViewInfo); + let cases: [(&str, bool, Gesture); 6] = [ + ("a second click", false, |s, v| { + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, 55.0, 10.0), v).unwrap(); + } + }), + ("a drag", false, |s, v| { + for (kind, x) in [(PointerKind::Down, 75.0), (PointerKind::Drag, 85.0), (PointerKind::Drag, 95.0), (PointerKind::Up, 95.0)] { + s.pointer(&PointerEvent::new(kind, x, 10.0), v).unwrap(); + } + }), + ("two clicks", false, |s, v| { + for x in [55.0, 135.0] { + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, x, 10.0), v).unwrap(); + } + } + }), + ("a Cmd-click", false, |s, v| { + let cmd = Mods { cmd: true, ..Default::default() }; + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, 500.0, 300.0).with_mods(cmd), v).unwrap(); + } + }), + ("a Cmd-drag and a click", false, |s, v| { + let cmd = Mods { cmd: true, ..Default::default() }; + for (kind, y) in [(PointerKind::Down, 10.0), (PointerKind::Drag, 110.0), (PointerKind::Up, 110.0)] { + s.pointer(&PointerEvent::new(kind, 305.0, y).with_mods(cmd), v).unwrap(); + } + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, 55.0, 10.0), v).unwrap(); + } + }), + ("an arrow key", true, |s, v| { + s.tool_key(ToolKey::Right, Mods::default(), v).unwrap(); + }), + ]; + let v = ViewInfo::default(); + let start = |swatches: bool| { + let mut s = session(); + if swatches { + s.select_tool("shapeBuilder", v).unwrap(); + s.set_tool_option_cmd(&json!({"key": "pickColorFrom", "value": "swatches"})).unwrap(); + } + square_row(&mut s); + click_row(&mut s, v); + s + }; + for (what, swatches, send) in cases { + // One after the other: the merge applied before the gesture, and each merge after its own. + let mut one = start(swatches); + while one.tool_waiting() { + one.tool_poll(v, true).unwrap(); + } + send(&mut one, v); + while one.tool_waiting() { + one.tool_poll(v, true).unwrap(); + } + // While the merge is computed. + let mut s = start(swatches); + let revision = s.doc().unwrap().revision; + let from = s.input_count(); + send(&mut s, v); + let to = s.input_count(); + assert!(s.tool_editing() && !s.input_applied(from, to), "{what}: held"); + assert_eq!(s.doc().unwrap().revision, revision, "{what}: nothing made before the merge"); + while s.tool_waiting() { + s.tool_poll(v, true).unwrap(); + } + assert!(!s.tool_editing() && s.input_applied(from, to), "{what}"); + assert!(undated(&s) == undated(&one), "{what}: the document the gestures make one after the other"); + assert_eq!(s.doc().unwrap().selection, one.doc().unwrap().selection, "{what}"); + assert_eq!(s.paint.fill, one.paint.fill, "{what}"); + let labels = |s: &Session| s.doc().unwrap().history.undo.iter().map(|e| e.label.clone()).collect::>(); + assert_eq!(labels(&s), labels(&one), "{what}"); + // The click's merge alone. + let mut merged = start(swatches); + while merged.tool_waiting() { + merged.tool_poll(v, true).unwrap(); + } + let changed = labels(&s).len() > labels(&merged).len() + || s.paint.fill != merged.paint.fill + || s.doc().unwrap().selection != merged.doc().unwrap().selection; + assert!(changed, "{what}: the gesture did something"); + } +} + +/// A new revision of the document made outside a command while a merge is computed on another +/// thread keeps the merge when the selected art is as it was (the next poll applies it, as the +/// command computes it), and drops it when the art changed. +#[test] +fn shape_builder_keeps_a_merge_over_a_new_revision_of_the_same_art() { + use vectorcraft_tools::builder::merges_computed; + let v = ViewInfo::default(); + let mut s = session(); + square_row(&mut s); + click_row(&mut s, v); + let computed = merges_computed(); + s.active_mut().unwrap().revision += 1; + s.tool_poll(v, true).unwrap(); + assert!(!s.tool_editing()); + assert_eq!(merges_computed(), computed, "the merge computed on another thread"); + assert_eq!(s.journal.last().unwrap().0, "shapeBuilder.merge"); + let mut direct = session(); + square_row(&mut direct); + direct.execute("shapeBuilder.merge", &json!({"points": [[15, 10]]})).unwrap(); + assert!(undated(&s) == undated(&direct), "the same document as the command gives"); + // The art changed: the merge is dropped. + let mut s = session(); + let ids = square_row(&mut s); + click_row(&mut s, v); + let journal = s.journal.len(); + let st = s.active_mut().unwrap(); + Arc::make_mut(&mut st.doc).node_mut(ids[1]).unwrap().transform(Affine::translate((0.0, 50.0)), false); + st.revision += 1; + s.tool_poll(v, true).unwrap(); + assert!(!s.tool_editing()); + assert_eq!(s.journal.len(), journal, "nothing merged"); + assert_eq!(layer_children(&s), 40); +} + +/// A merge computed for the tool Cmd lent the selection tool's drag away from is applied before +/// a command too. +#[test] +fn shape_builder_merge_of_the_lending_tool_comes_first() { + let v = ViewInfo::default(); + let mut s = session(); + square_row(&mut s); + click_row(&mut s, v); + let lent = s.make_tool("selection"); + s.lender = Some(std::mem::replace(&mut s.tool, lent)); + assert!(s.tool_editing()); + let journal = s.journal.len(); + s.execute("object.move", &json!({"dx": 0, "dy": 100})).unwrap(); + assert!(!s.tool_editing()); + assert_eq!(s.journal.since(journal).first().map(|j| j.0.as_str()), Some("shapeBuilder.merge")); + assert_eq!(s.tool_id(), "selection", "the lent tool stays active until the release"); + assert_eq!(s.lender.as_ref().map(|t| t.id()), Some("shapeBuilder")); +} + #[test] fn shape_builder_tool_merges_the_narrow_shape_it_highlights_in_a_wide_selection() { let mut s = session(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index a2e074377..9895bb2cd 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -75,6 +75,23 @@ pub enum UiRequest { /// Place a file with these `file.place` params: a click or drag of the place cursor, when /// [`Session::place_cursor_requests`] is set. Place(Value), + /// A press held while the tool computed an edit on another thread was made now, at `pos` in the + /// document with [`crate::DocState::uid`] `doc`, with tool `tool` active (the selection tool Cmd + /// lent): the frontend does what it does after a press it hands over itself. + HeldPress { + doc: Option, + pos: Point, + tool: &'static str, + }, +} + +/// Pointer or key input that came while the active tool computed an edit on another thread +/// ([`Tool::editing`]), made in order once the edit is applied ([`Session::tool_poll`], +/// [`Session::finish_tool_edit`]). +#[derive(Clone, Copy, Debug)] +pub(crate) enum HeldInput { + Pointer(PointerEvent), + Key(ToolKey, Mods), } impl Session { @@ -98,6 +115,7 @@ impl Session { /// Choose a tool (the toolbar, its shortcut, MCP), finishing any pending tool work first. pub fn select_tool(&mut self, id: &str, view: ViewInfo) -> Result<()> { + self.finish_tool_edit_later()?; // Choosing a perspective tool shows the document's perspective grid; Hide Grid hides it // again, the tool staying chosen. if matches!(id, "perspectiveGrid" | "perspectiveSelection") @@ -111,6 +129,8 @@ impl Session { /// Switch to tool `id` without choosing it afresh (back from a temporary tool: a hidden /// perspective grid stays hidden), finishing any pending tool work first. pub fn switch_tool(&mut self, id: &str, view: ViewInfo) -> Result<()> { + // The edit the tool computes on another thread and the input held for it come first. + self.finish_tool_edit_later()?; self.give_back_tool(view)?; if self.tool.id() == id { return Ok(()); @@ -327,8 +347,85 @@ impl Session { self.tool_panic.take().map_or(Ok(()), Err) } - /// Feed a pointer event to the active tool. Returns requests for the UI. + /// Feed a pointer event to the active tool. Returns requests for the UI. While the tool computes + /// an edit on another thread, a press and what follows it are held and made once the edit is + /// applied, in order ([`Session::tool_poll`]); moves of the pointer with no button held go to + /// the tool at once. pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result> { + let n = self.next_input(); + if ev.kind != PointerKind::Move && (!self.held_input.is_empty() || self.tool_edit_pending()) { + self.held_input.push_back((n, HeldInput::Pointer(*ev), view)); + return Ok(vec![]); + } + let r = self.pointer_now(ev, view); + self.note_edit_start(n); + r + } + + /// The number of the next pointer or key event handed to the session. + fn next_input(&mut self) -> u64 { + let n = self.input_count; + self.input_count = n.wrapping_add(1); + n + } + + /// How many pointer and key events have been handed to the session ([`Session::pointer`], + /// [`Session::tool_key`]); with [`Session::input_applied`], a frontend tells when the events it + /// handed over have been made. + pub fn input_count(&self) -> u64 { + self.input_count + } + + /// Have the events numbered `from..to` ([`Session::input_count`] before and after handing them + /// over) been made, and the edit they started on another thread applied? + pub fn input_applied(&self, from: u64, to: u64) -> bool { + let ours = |n: &u64| (from..to).contains(n); + !self.held_input.iter().any(|(n, ..)| ours(n)) && !(self.tool_edit_pending() && self.edit_started_by.as_ref().is_some_and(ours)) + } + + /// After event `n` was made: the event that started the edit being computed, if one is. + fn note_edit_start(&mut self, n: u64) { + if !self.tool_edit_pending() { + self.edit_started_by = None; + } else if self.edit_started_by.is_none() { + self.edit_started_by = Some(n); + } + } + + /// Is the active tool, or the tool Cmd lent the selection tool's drag away from, computing an + /// edit on another thread? + fn tool_edit_pending(&self) -> bool { + self.tool.editing() || self.lender.as_ref().is_some_and(|t| t.editing()) + } + + /// Makes the input held while the tool computed an edit, in order, until a held gesture starts + /// another such edit. + fn replay_held_input(&mut self) -> Result> { + let mut ui = vec![]; + let mut failed = None; + while !self.tool_edit_pending() + && let Some((n, input, view)) = self.held_input.pop_front() + { + let r = match input { + HeldInput::Pointer(ev) => self.pointer_now(&ev, view).map(|mut r| { + if ev.kind == PointerKind::Down { + r.push(UiRequest::HeldPress { doc: self.active().map(|d| d.uid), pos: ev.pos, tool: self.tool.id() }); + } + r + }), + HeldInput::Key(key, mods) => self.tool_key_now(key, mods, view), + }; + self.note_edit_start(n); + match r { + Ok(r) => ui.extend(r), + // The input after it is still made, as it was when each event came on its own. + Err(e) => failed = failed.or(Some(e)), + } + } + failed.map_or(Ok(ui), Err) + } + + fn pointer_now(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result> { self.last_view = view; match ev.kind { PointerKind::Down | PointerKind::Drag => self.pointer_held = true, @@ -421,19 +518,148 @@ impl Session { self.tool.wants_samples() } - /// Is the active tool waiting for work it runs on another thread ([`Tool::waiting`])? + /// Is the active tool waiting for work it runs on another thread ([`Tool::waiting`]), is input + /// held until an edit it computes is applied, or are requests for the UI left from making it? pub fn tool_waiting(&self) -> bool { - self.tool.waiting() + self.tool.waiting() || !self.held_input.is_empty() || !self.held_ui.is_empty() } /// Lets the active tool check on the work [`Session::tool_waiting`] reports, and with `wait` - /// wait for it to finish ([`Tool::poll`]). - pub fn tool_poll(&mut self, view: ViewInfo, wait: bool) -> Result<()> { - self.with_tool_cx(view, |t, cx| t.poll(cx, wait)); - self.take_tool_panic() + /// wait for it to finish ([`Tool::poll`]); an edit the work made is applied, and then the input + /// held for it is made. Returns requests for the UI, those left from making held input where + /// nothing could take them first ([`Session::finish_tool_edit_later`]). + pub fn tool_poll(&mut self, view: ViewInfo, wait: bool) -> Result> { + if self.settling_tool_edit { + return Ok(vec![]); + } + self.settling_tool_edit = true; + let r = self.tool_poll_now(view, wait); + self.settling_tool_edit = false; + // On an error the requests left wait for the next poll. + let r = r?; + let mut ui = std::mem::take(&mut self.held_ui); + ui.extend(r); + Ok(ui) } + fn tool_poll_now(&mut self, view: ViewInfo, wait: bool) -> Result> { + let acts = self.with_tool_cx(view, |t, cx| t.poll(cx, wait)); + self.take_tool_panic()?; + let applied = !acts.is_empty(); + let mut ui = self.apply_actions(acts)?; + if !self.tool_edit_pending() { + self.edit_started_by = None; + // The input held for an edit applied now is made at the next poll, so what waits for the + // edit alone (a `ui.pointer` reply) sees the document as the edit left it. + if !applied { + ui.extend(self.replay_held_input()?); + } + } + Ok(ui) + } + + /// Is the active tool computing an edit on another thread ([`Tool::editing`]), or is input held + /// until it is applied? + pub fn tool_editing(&self) -> bool { + self.tool_edit_pending() || !self.held_input.is_empty() + } + + /// Waits for the edit the active tool computes on another thread and applies it + /// ([`Tool::finish_edit`]), then makes the input held for it, waiting in turn for an edit that + /// starts, so what runs next sees the document as the gestures left it. Commands, saves, option + /// changes, interactions, undo scopes and tool or document switches call it first. Returns + /// requests for the UI, with those left from earlier ([`Session::finish_tool_edit_later`]); on + /// an error, the next [`Session::tool_poll`] returns them. + pub fn finish_tool_edit(&mut self) -> Result> { + if self.settling_tool_edit { + return Ok(vec![]); + } + let mut ui = std::mem::take(&mut self.held_ui); + if self.tool_editing() { + self.settling_tool_edit = true; + let r = self.finish_tool_edit_now(); + self.settling_tool_edit = false; + match r { + Ok(r) => ui.extend(r), + Err(e) => { + ui.append(&mut self.held_ui); + self.held_ui = ui; + return Err(e); + } + } + } + Ok(ui) + } + + /// [`Session::finish_tool_edit`] for a caller that cannot hand requests to the UI (a command, a + /// tool switch): the next [`Session::tool_poll`] returns them, and [`Session::tool_waiting`] + /// holds until then. + pub(crate) fn finish_tool_edit_later(&mut self) -> Result<()> { + let ui = self.finish_tool_edit()?; + self.held_ui.extend(ui); + Ok(()) + } + + fn finish_tool_edit_now(&mut self) -> Result> { + let mut ui = vec![]; + let mut failed = None; + loop { + match self.finish_pending_edit() { + Ok(r) => ui.extend(r), + Err(e) => failed = failed.or(Some(e)), + } + // A tool that cannot finish its edit keeps the input held. + if self.tool_edit_pending() { + break; + } + self.edit_started_by = None; + if self.held_input.is_empty() { + break; + } + match self.replay_held_input() { + Ok(r) => ui.extend(r), + Err(e) => failed = failed.or(Some(e)), + } + } + match failed { + None => Ok(ui), + Some(e) => { + self.held_ui.extend(ui); + Err(e) + } + } + } + + /// Waits for the edit being computed and applies it: the active tool's, or that of the tool + /// Cmd lent the selection tool's drag away from, which is active meanwhile. + fn finish_pending_edit(&mut self) -> Result> { + if self.tool.editing() { + let acts = self.with_tool_cx(self.last_view, |t, cx| t.finish_edit(cx)); + self.take_tool_panic()?; + return self.apply_actions(acts); + } + let Some(lender) = self.lender.take_if(|t| t.editing()) else { return Ok(vec![]) }; + let lent = std::mem::replace(&mut self.tool, lender); + let acts = self.with_tool_cx(self.last_view, |t, cx| t.finish_edit(cx)); + let r = self.take_tool_panic().and_then(|()| self.apply_actions(acts)); + self.lender = Some(std::mem::replace(&mut self.tool, lent)); + r + } + + /// A key for the active tool. While the tool computes an edit on another thread, the key is held + /// and made once the edit is applied, after the input held before it. pub fn tool_key(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result> { + let n = self.next_input(); + if self.tool_editing() { + self.held_input.push_back((n, HeldInput::Key(key, mods), view)); + return Ok(vec![]); + } + let r = self.tool_key_now(key, mods, view); + self.note_edit_start(n); + r + } + + fn tool_key_now(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result> { // 1–4 pick the active perspective plane while the grid is shown. if let Some(plane) = self.plane_key(key) { self.execute("perspective.plane.set", &serde_json::json!({ "plane": plane.id() }))?; @@ -583,7 +809,8 @@ impl Session { /// tools' a crosshair, [`Cursor::precise`]). With Cmd held, the pointer of the selection tool a /// press would borrow ([`Self::lend_selection_tool`]). pub fn cursor(&mut self, p: Point, mods: Mods, view: ViewInfo) -> Cursor { - let lent = if mods.cmd && self.lender.is_none() { self.lent_tool(self.tool.id(), &mods) } else { None }; + // While an edit is computed, a Cmd press is held for the active tool, which shows it is busy. + let lent = if mods.cmd && self.lender.is_none() && !self.tool_editing() { self.lent_tool(self.tool.id(), &mods) } else { None }; let c = match lent { Some(id) => { let lent = self.make_tool(id); @@ -601,6 +828,9 @@ impl Session { self.tool.options() } pub fn set_tool_option(&mut self, key: &str, v: &Value) { + // The active tool's edit being computed is applied with the options it was started with. A + // failure to apply it was the edit's own, and the option is set all the same. + let _ = self.finish_tool_edit_later(); self.tool.set_option(key, v); self.tool_set_since_reset = true; self.keep_tool_settings(); @@ -652,6 +882,10 @@ impl Session { { return Err(format!("unknown tool `{t}`")); } + // The active tool's merge being computed is applied with the options it was started with. + if tool.is_none_or(|t| t == self.tool.id()) { + self.finish_tool_edit_later().map_err(|e| e.to_string())?; + } let mut values = p.get("values").and_then(Value::as_object).cloned().unwrap_or_default(); if let Some(k) = p.get("key").and_then(Value::as_str) { values.insert(k.to_string(), p.get("value").cloned().unwrap_or(Value::Null)); diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 1881fafc4..c584e1ed1 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -219,6 +219,52 @@ fn every_command_with_junk_params() { ); } +/// Each document's uid, revision and selected ids, the active document and the current paint. +type ToolEditState = (Vec<(u64, u64, Vec)>, Option, vectorcraft_tools::PaintDefaults); + +/// What a command that runs while the active tool computes an edit on another thread must leave +/// as it was: every document's revision and selection, which document is active, and the current +/// paint. +fn tool_edit_state(s: &Session) -> ToolEditState { + let docs = s.documents().iter().map(|d| (d.uid, d.revision, d.selection.objects.iter().map(|i| i.0).collect())).collect(); + (docs, s.active_index(), s.paint.clone()) +} + +/// Every command that runs at once while the active tool computes an edit on another thread (a +/// Shape Builder merge), rather than applying the edit first (`waits_for_tool_edit`), leaves the +/// documents' revisions and selections, the active document and the current paint as they were, +/// with `{}`, junk params and params that turn its options on and off: the edit, computed for the +/// document as it was, still applies after it. +#[test] +fn commands_that_do_not_wait_for_a_tool_edit_change_no_document() { + let path = safe_path(); + let mut failures = vec![]; + for fx in [Fixture::Single, Fixture::Multi] { + for c in command_specs().iter().filter(|c| !vectorcraft_engine::waits_for_tool_edit(c.id)) { + let mut cases = junk_params(c.params, &path); + if heavy(c.id) { + cases.truncate(12); + } + for k in param_keys(c.params) { + for v in [json!(true), json!(false), json!(1), json!("on")] { + cases.push(json!({ k.as_str(): v })); + } + } + for p in &cases { + let mut s = fx.session(); + let before = tool_edit_state(&s); + let _ = catch_quiet(|| no_panic(s.execute(c.id, p))); + if tool_edit_state(&s) != before { + failures.push(format!("{} {p} [{fx:?}]", c.id)); + } + } + } + } + failures.sort(); + failures.dedup_by(|a, b| a.split(' ').next() == b.split(' ').next()); + assert!(failures.is_empty(), "{} commands change a document without waiting for a tool edit:\n{}", failures.len(), failures.join("\n")); +} + /// Junk inside the obvious structured params: ids arrays, anchors, matrices, colours, stops. #[test] fn structured_junk() { diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index c16e0023a..710f5ace8 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -173,6 +173,8 @@ impl Headless { UiRequest::Place(p) => { self.session.execute("file.place", &p).map_err(|e| e.to_string())?; } + // The host keeps its view's artboard on a press, as for the presses it hands over. + UiRequest::HeldPress { .. } => {} } } Ok(()) @@ -195,10 +197,11 @@ impl Headless { let ev = PointerEvent { kind, pos: Point::new(x, y), mods, pressure: PointerEvent::json_pressure(e) }; let reqs = self.session.pointer(&ev, self.view).map_err(|e| e.to_string())?; self.apply_ui_requests(reqs, &mut requests)?; - // With no frames to show the highlight in later, a hover waits for the work the tool - // runs on another thread. - if kind == PointerKind::Move && self.session.tool_waiting() { - self.session.tool_poll(self.view, true).map_err(|e| e.to_string())?; + // With no frames to show the highlight or apply an edit in later, each event waits for + // the work the tool runs on another thread (a hover's map, a release's merge). + if self.session.tool_waiting() { + let reqs = self.session.tool_poll(self.view, true).map_err(|e| e.to_string())?; + self.apply_ui_requests(reqs, &mut requests)?; } let hold = PointerEvent::json_hold(e); if hold > 0.0 { diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 1b8991a93..15f8b5db2 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -498,6 +498,15 @@ fn headless_hover_waits_for_the_shape_builder_map() { assert!(!r.is_error, "{r:?}"); assert!(!h.session.tool_waiting()); assert!(!h.session.overlays(h.view).is_empty(), "the region under the pointer is highlighted"); + // A click's reply comes once the merge the tool computes on another thread is applied, with + // the selection after it. + let click = json!({"events": [{"kind": "down", "x": 115, "y": 110}, {"kind": "up", "x": 115, "y": 110}]}); + let r = crate::call_tool(&mut h, "pointer_gesture", &click); + assert!(!r.is_error, "{r:?}"); + assert!(!h.session.tool_waiting() && !h.session.tool_editing()); + let text: String = r.content.iter().filter_map(|c| c["text"].as_str()).collect(); + let reply: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(reply["selection"].as_array().map(Vec::len), Some(41), "the overlap and the 40 squares, two of them cut: {reply}"); } /// inspect_document reads back the paint type's characters show, as add_text and set_paint give it. diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index 2ed8ff6d6..ab0095181 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -32,7 +32,7 @@ mod live_paint; mod lookup; mod map; -use cache::RegionCache; +use cache::{BACKGROUND_SEGMENTS, CacheKey, RegionCache}; #[cfg(test)] use live_paint::LpHover; use live_paint::lp_color; @@ -42,7 +42,10 @@ pub use lookup::{ leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap, path_contains, shape_builder_shapes, shapes_for, sources, visible_edge_near, visible_face_at, }; -pub use map::{BuilderMap, HATCH, HIGHLIGHT_RED, Hit, Touched, hatch, maps_built, sample_polyline, sorted_roots}; +pub use map::{ + BuilderMap, HATCH, HIGHLIGHT_RED, Hit, MergeGeometry, MergeInput, PreparedMerge, Rest, Touched, hatch, maps_built, merge_geometry, merge_inputs, + merges_computed, sample_polyline, sorted_roots, +}; /// Small, Medium and Large gap lengths in points. pub const GAP_LENGTHS: [f64; 3] = [3.0, 6.0, 12.0]; @@ -166,6 +169,52 @@ pub struct ShapeBuilderTool { /// The pointer went more than [`CLICK_TOL_PX`] from the press during the drag, so the gesture /// is not a click. far: bool, + /// The merge of the last gesture over a large selection, being computed on another thread. + merging: Option, + /// That merge once computed, for `shapeBuilder.merge` ([`Tool::shape_builder_merge`]). + prepared: Option>, + /// A gesture made while the map of a large selection is being built on another thread. + deferred: Option, +} + +/// A Shape Builder gesture made while the map of the selection was being built on another +/// thread: the map key and the inputs it was made over, its pointer events, which [`Tool::poll`] +/// replays once the map is there, and whether the release has come. +struct Deferred { + key: CacheKey, + shapes: Vec, + events: Vec, + released: bool, +} + +/// A Shape Builder merge being computed on another thread: the map key it is for (the document, +/// its revision and the selection), the `shapeBuilder.merge` params that apply it, what it was +/// computed from, and the channel the result arrives on. +struct PendingMerge { + key: CacheKey, + params: Value, + map: Arc, + touched: Touched, + gap: f64, + inputs: Vec, + done: Receiver, +} + +/// Computes [`merge_geometry`] on a new thread; None when no thread starts. +#[cfg(not(target_arch = "wasm32"))] +fn spawn_merge(map: Arc, touched: Touched, gap: f64, inputs: Vec) -> Option> { + let (tx, done) = std::sync::mpsc::channel(); + let spawned = std::thread::Builder::new().name("vectorcraft-shape-builder-merge".into()).spawn(move || { + // The tool may have dropped the receiver meanwhile (another tool, another document). + let _ = tx.send(merge_geometry(&map, &touched, gap, &inputs)); + }); + spawned.is_ok().then_some(done) +} + +/// The web build has no threads: the merge runs in the command. +#[cfg(target_arch = "wasm32")] +fn spawn_merge(_map: Arc, _touched: Touched, _gap: f64, _inputs: Vec) -> Option> { + None } /// How close (screen px) the pointer must come to a line piece or an edge to delete it. @@ -210,6 +259,132 @@ impl ShapeBuilderTool { alt.then(|| cx.tol(LINE_TOL_PX)) } + /// Starts computing the merge `params` describe on another thread when the map of the + /// selection is current and has more than [`BACKGROUND_SEGMENTS`] segments; `false` when it + /// did not (small selections, the web build), and the command computes it. + fn start_merge(&mut self, cx: &ToolContext, params: &Value) -> bool { + let key = RegionCache::key_for(cx, self.opts.open_filled_as_closed); + if cfg!(target_arch = "wasm32") || params.get("splitStroke").is_some() || self.cache.key.as_ref() != Some(&key) { + return false; + } + let map = Arc::clone(&self.cache.map); + let segments: usize = map.shapes.iter().map(|s| s.path.subpaths.iter().map(|sp| sp.segment_count()).sum::()).sum(); + if segments <= BACKGROUND_SEGMENTS { + return false; + } + // What the command reads from its params, read the same way. + let num = |v: &Value| v.as_f64().unwrap_or(f64::NAN); + let pts: Vec = params["points"].as_array().map(|a| a.iter().map(|q| Point::new(num(&q[0]), num(&q[1]))).collect()).unwrap_or_default(); + let marquee = params["rect"].as_array().filter(|r| r.len() == 4).map(|r| { + let (x, y) = (num(&r[0]), num(&r[1])); + (Point::new(x, y), Point::new(x + num(&r[2]), y + num(&r[3]))) + }); + let piece_tol = (params["erase"] == json!(true)).then(|| params["tolerance"].as_f64().unwrap_or(2.0).clamp(0.0, 1000.0)); + let touched = map.merge_touched(&pts, marquee, piece_tol); + if touched.is_empty() { + return false; + } + let roots = sorted_roots(cx.doc, &cx.selection.objects); + let (_, leaves) = shape_builder_shapes(cx.doc, &roots, self.opts.open_filled_as_closed); + let inputs = merge_inputs(&leaves, self.opts.open_filled_as_closed); + let gap = self.gap(); + let Some(done) = spawn_merge(Arc::clone(&map), touched.clone(), gap, inputs.clone()) else { return false }; + self.merging = Some(PendingMerge { key, params: params.clone(), map, touched, gap, inputs, done }); + true + } + + /// Takes the merge being computed on another thread once it has arrived, or with `wait` once + /// it arrives: the action that applies it, while the document, the selection and its inputs are + /// those it was computed for. When one of them changed (an edit or a selection made other than + /// by a command, which applies the merge first), the merge is dropped at once, without waiting + /// for the thread. A copy of the document (a save stamping its dates) and a new revision that leaves + /// the inputs as they were keep the merge. When the thread failed, the command computes the + /// merge and reports what went wrong. + fn poll_merge(&mut self, cx: &ToolContext, wait: bool) -> Vec { + let Some(mut m) = self.merging.take() else { return vec![] }; + let now = RegionCache::key_for(cx, m.key.3); + if (now.1.0, &now.2) != (m.key.1.0, &m.key.2) { + return vec![]; + } + // A new revision that leaves the selection's inputs as they were (a view setting or a + // preference that redraws the document) keeps the merge. + if now.1 != m.key.1 { + if !self.inputs_are(cx, m.key.3, &m.map.shapes) { + return vec![]; + } + m.key = now; + } + let got = if wait { m.done.recv().map_err(|_| TryRecvError::Disconnected) } else { m.done.try_recv() }; + let geometry = match got { + Ok(g) => Some(g), + Err(TryRecvError::Empty) => { + self.merging = Some(m); + return vec![]; + } + Err(TryRecvError::Disconnected) => None, + }; + self.prepared = geometry.map(|geometry| Arc::new(PreparedMerge { map: m.map, touched: m.touched, gap: m.gap, inputs: m.inputs, geometry })); + vec![Action::Exec("shapeBuilder.merge".into(), m.params)] + } + + /// Is the map of the selection being built on another thread (it is not current yet)? + fn map_building(&mut self, cx: &ToolContext) -> bool { + self.cache.ready(cx, self.opts.open_filled_as_closed).is_none() && self.cache.pending.is_some() + } + + /// Are the inputs of the selection in `cx` `shapes`? The map, or the map being built, is filed + /// under the key of `cx` first when they are its inputs ([`RegionCache::rekey`]). + fn inputs_are(&mut self, cx: &ToolContext, open_filled_as_closed: bool, shapes: &[po::Shape]) -> bool { + let _ = self.cache.rekey(cx, open_filled_as_closed); + let key = RegionCache::key_for(cx, open_filled_as_closed); + if self.cache.key.as_ref() == Some(&key) { + return self.cache.shapes == shapes; + } + if let Some(p) = self.cache.pending.as_ref().filter(|p| p.key == key) { + return p.shapes == shapes; + } + let roots = sorted_roots(cx.doc, &cx.selection.objects); + shape_builder_shapes(cx.doc, &roots, open_filled_as_closed).0 == shapes + } + + /// Is a gesture made while the map was being built released and waiting for the map? + fn deferred_released(&self) -> bool { + self.deferred.as_ref().is_some_and(|d| d.released) + } + + /// Replays the released gesture made while the map was being built once the map is there, or + /// with `wait` once it is built, and with `wait` also waits for the merge it starts: the + /// actions the gesture gives. When the document, the selection or its inputs changed since the + /// gesture, it is dropped; a new revision that leaves them as they were keeps it. + fn replay_deferred(&mut self, cx: &ToolContext, wait: bool) -> Vec { + if !self.deferred_released() { + return vec![]; + } + let ofac = self.opts.open_filled_as_closed; + let now = RegionCache::key_for(cx, ofac); + if let Some(mut d) = self.deferred.take() { + if (d.key.1.0, &d.key.2) != (now.1.0, &now.2) || (d.key.1 != now.1 && !self.inputs_are(cx, ofac, &d.shapes)) { + return vec![]; + } + d.key = now; + self.deferred = Some(d); + } + if wait { + self.cache.get(cx, self.opts.open_filled_as_closed); + } else if self.map_building(cx) { + return vec![]; + } + let Some(d) = self.deferred.take() else { return vec![] }; + let mut acts = vec![]; + for ev in &d.events { + acts.extend(self.pointer(cx, ev)); + } + if wait { + acts.extend(self.poll_merge(cx, true)); + } + acts + } + /// Adds what the drag from `from` to `to` touches, picked as `shapeBuilder.merge` picks it. fn touch(&mut self, cx: &ToolContext, from: Option, to: Point) { let pts: Vec = from.into_iter().chain([to]).collect(); @@ -271,6 +446,8 @@ impl Tool for ShapeBuilderTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let p = ev.pos; + // The command has taken the merge computed for the last gesture by now. + self.prepared = None; match ev.kind { PointerKind::Move => { self.alt = ev.mods.alt; @@ -279,7 +456,8 @@ impl Tool for ShapeBuilderTool { vec![] } PointerKind::Down => { - if cx.selection.is_empty() { + // While a merge is being computed, a press starts no gesture. + if cx.selection.is_empty() || self.editing() { return vec![]; } self.dragging = true; @@ -288,11 +466,35 @@ impl Tool for ShapeBuilderTool { self.points = vec![p]; self.far = false; self.touched = Touched::default(); + // While the map is being built on another thread, the gesture is recorded and + // replayed once the map is there. + if self.map_building(cx) { + let key = RegionCache::key_for(cx, self.opts.open_filled_as_closed); + let shapes = match self.cache.pending.as_ref().filter(|p| p.key == key) { + Some(p) => p.shapes.clone(), + None => self.cache.inputs(cx, self.opts.open_filled_as_closed).0, + }; + self.deferred = Some(Deferred { key, shapes, events: vec![*ev], released: false }); + return vec![]; + } self.touch(cx, None, p); vec![] } PointerKind::Drag if self.dragging => { self.at = Some(p); + if let Some(d) = &mut self.deferred { + // The drag's line or marquee shows meanwhile. + d.events.push(*ev); + if let Some((a, _)) = self.marquee { + self.marquee = Some((a, p)); + } else if self.opts.straight_line { + self.points.truncate(1); + self.points.push(p); + } else if self.points.last().is_none_or(|q| q.distance(p) >= cx.tol(1.0)) { + self.points.push(p); + } + return vec![]; + } if self.points.first().is_some_and(|s| s.distance(p) > cx.tol(CLICK_TOL_PX)) { self.far = true; } @@ -313,6 +515,13 @@ impl Tool for ShapeBuilderTool { } PointerKind::Up if self.dragging => { self.dragging = false; + if let Some(d) = &mut self.deferred { + d.events.push(*ev); + d.released = true; + self.points.clear(); + self.marquee = None; + return vec![]; + } if self.marquee.is_some() { self.marquee_to(cx, p); } else if self.opts.straight_line { @@ -358,6 +567,10 @@ impl Tool for ShapeBuilderTool { if self.opts.swatches { params["pickColorFrom"] = json!("swatches"); } + // A large selection's merge is computed on another thread; `poll` applies it. + if self.start_merge(cx, ¶ms) { + return vec![]; + } vec![Action::Exec("shapeBuilder.merge".into(), params)] } _ => vec![], @@ -366,10 +579,13 @@ impl Tool for ShapeBuilderTool { fn overlays(&self, cx: &ToolContext) -> Vec { let mut out = vec![]; - let map = &self.cache.map; - let color = if self.alt { HIGHLIGHT_RED } else { HATCH }; - let show: Vec = if self.dragging { - let t = &self.touched; + // While a merge is being computed, what its gesture touched stays highlighted. + let map = self.merging.as_ref().map_or(&self.cache.map, |m| &m.map); + // The mode of the gesture being merged, else the drag's or the hover's. + let alt = self.merging.as_ref().filter(|_| !self.dragging).map_or(self.alt, |m| m.params["erase"] == json!(true)); + let color = if alt { HIGHLIGHT_RED } else { HATCH }; + let touched = if self.dragging { Some(&self.touched) } else { self.merging.as_ref().map(|m| &m.touched) }; + let show: Vec = if let Some(t) = touched { let regions = t.regions.iter().map(|&i| Hit::Region(i)); regions.chain(t.lines.iter().map(|&i| Hit::Line(i))).chain(map.erased_edges(t).into_iter().map(Hit::Edge)).collect() } else { @@ -378,7 +594,7 @@ impl Tool for ShapeBuilderTool { for hit in show { match hit { // Highlight Fill off: merge mode leaves the regions as they are. - Hit::Region(_) if !self.alt && !self.opts.highlight_fill => {} + Hit::Region(_) if !alt && !self.opts.highlight_fill => {} Hit::Region(i) => { if let Some((_, bp, _)) = map.regions.get(i) { out.push(Overlay::Path { path: hatch(bp, cx.tol(4.0)), color, width: 1.0, dashed: false }); @@ -421,7 +637,10 @@ impl Tool for ShapeBuilderTool { fn cursor(&self, _cx: &ToolContext, _p: Point, m: Mods) -> Cursor { // A drag keeps the mode it started in; hovering follows the key. let erase = if self.dragging { self.alt } else { m.alt }; - if erase { + if self.editing() { + // The merge of the last gesture, or the map it waits for, is being computed. + Cursor::Busy + } else if erase { Cursor::ShapeBuilderErase } else if self.stroke.is_some() && !self.dragging { // Over a stroke a click splits. @@ -436,30 +655,53 @@ impl Tool for ShapeBuilderTool { } fn waiting(&self) -> bool { - self.cache.waiting() + self.cache.waiting() || self.merging.is_some() || self.deferred.is_some() } - fn poll(&mut self, cx: &ToolContext, wait: bool) { - if !self.cache.waiting() || self.dragging { - return; + fn editing(&self) -> bool { + self.merging.is_some() || self.deferred_released() + } + + fn finish_edit(&mut self, cx: &ToolContext) -> Vec { + let mut acts = self.replay_deferred(cx, true); + acts.extend(self.poll_merge(cx, true)); + acts + } + + /// Choosing another tool or another document applies the merge being computed first. + fn deactivate(&mut self, cx: &ToolContext) -> Vec { + self.finish_edit(cx) + } + + fn poll(&mut self, cx: &ToolContext, wait: bool) -> Vec { + let mut acts = self.poll_merge(cx, wait); + if !acts.is_empty() || self.dragging { + return acts; } // The map that arrives may be for an earlier revision; the hover then starts the current // one's, which `wait` waits for too. - loop { + while self.cache.waiting() { self.cache.settle(wait); if let Some(p) = self.at { self.hover_at(cx, p); } - if !wait || !self.cache.waiting() { + if !wait { break; } } + // A gesture made while the map was being built goes on once it is there. + acts.extend(self.replay_deferred(cx, wait)); + acts } fn shape_builder_map(&self) -> Option<(Arc, f64)> { self.cache.key.is_some().then(|| (Arc::clone(&self.cache.map), self.cache.gap)) } + fn shape_builder_merge(&self) -> Option> { + self.prepared.clone() + } + /// With Pick Color From Color Swatches, the Left and Right arrow keys pick the color swatch /// before or after the current fill. fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { @@ -467,7 +709,8 @@ impl Tool for ShapeBuilderTool { } fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { - if !self.claims_key(cx, key) { + // While a merge is being computed, the arrows leave the current fill it takes as it was. + if !self.claims_key(cx, key) || self.editing() { return vec![]; } let list = color_swatches(cx.doc); diff --git a/crates/tools/src/builder/cache.rs b/crates/tools/src/builder/cache.rs index 27487e4c3..9a64d3826 100644 --- a/crates/tools/src/builder/cache.rs +++ b/crates/tools/src/builder/cache.rs @@ -8,18 +8,21 @@ pub(super) type CacheKey = (usize, (u64, u64), Vec, bool); /// Hovering builds the map of inputs with more segments than this on another thread /// ([`RegionCache::ready`]); smaller maps are built on the calling thread. -const BACKGROUND_SEGMENTS: usize = 128; +pub(super) const BACKGROUND_SEGMENTS: usize = 128; -/// A map being built on another thread: the key it is for, the drawn outlines of its inputs, and -/// the channel it arrives on. -struct Pending { - key: CacheKey, +/// A map being built on another thread: the key it is for, its inputs and their drawn outlines, +/// and the channel it arrives on. +pub(super) struct Pending { + pub(super) key: CacheKey, + pub(super) shapes: Vec, outlines: Vec, done: Receiver, } /// The planar map of the current selection, cached by document identity, revision, selection and -/// whether filled open paths count as closed. +/// whether filled open paths count as closed. When only the document's identity or revision +/// changed and the selection's inputs did not (a view setting, a preference, a copy of the +/// document), the map is kept under the new key ([`RegionCache::rekey`]). #[derive(Default)] pub(super) struct RegionCache { pub(super) key: Option, @@ -31,8 +34,12 @@ pub(super) struct RegionCache { pub(super) gap: f64, /// The outlines of the Shape Builder's inputs as they are drawn (see [`BuilderMap::stroke_at`]). pub(super) outlines: Vec, + /// The inputs the map was built from. + pub(super) shapes: Vec, + /// The last key whose inputs [`Self::rekey`] compared with the map's. + compared: Option, /// The map being built on another thread, if any. - pending: Option, + pub(super) pending: Option, /// The key of the last map whose build on another thread failed. failed: Option, /// How many times the map has been built. @@ -76,13 +83,13 @@ impl RegionCache { Self { live: true, ..Default::default() } } - fn key_for(cx: &ToolContext, open_filled_as_closed: bool) -> CacheKey { + pub(super) fn key_for(cx: &ToolContext, open_filled_as_closed: bool) -> CacheKey { (cx.doc as *const Document as usize, cx.revision, cx.selection.objects.clone(), open_filled_as_closed) } /// The inputs of the selection's map, back → front, and their drawn outlines (Shape Builder /// only). - fn inputs(&self, cx: &ToolContext, open_filled_as_closed: bool) -> (Vec, Vec) { + pub(super) fn inputs(&self, cx: &ToolContext, open_filled_as_closed: bool) -> (Vec, Vec) { let roots = sorted_roots(cx.doc, &cx.selection.objects); if self.live { (shapes_for(cx.doc, &roots).0, vec![]) @@ -93,8 +100,9 @@ impl RegionCache { } } - fn install(&mut self, key: CacheKey, map: BuilderMap, outlines: Vec) { + fn install(&mut self, key: CacheKey, map: BuilderMap, shapes: Vec, outlines: Vec) { self.map = Arc::new(map); + self.shapes = shapes; self.outlines = outlines; self.key = Some(key); #[cfg(test)] @@ -122,8 +130,12 @@ impl RegionCache { pub(super) fn get(&mut self, cx: &ToolContext, open_filled_as_closed: bool) -> &BuilderMap { let key = Self::key_for(cx, open_filled_as_closed); if self.key.as_ref() != Some(&key) { - let built = self.pending.take().filter(|p| p.key == key).and_then(|p| Some((p.done.recv().ok()?, p.outlines))); - let (map, outlines) = match built { + let inputs = self.rekey(cx, open_filled_as_closed); + if self.key.as_ref() == Some(&key) { + return &self.map; + } + let built = self.pending.take().filter(|p| p.key == key).and_then(|p| Some((p.done.recv().ok()?, p.shapes, p.outlines))); + let (map, shapes, outlines) = match built { Some(b) => { #[cfg(test)] { @@ -132,15 +144,45 @@ impl RegionCache { b } None => { - let (shapes, outlines) = self.inputs(cx, open_filled_as_closed); - (build_map(shapes, self.live, self.gap), outlines) + let (shapes, outlines) = inputs.unwrap_or_else(|| self.inputs(cx, open_filled_as_closed)); + (build_map(shapes.clone(), self.live, self.gap), shapes, outlines) } }; - self.install(key, map, outlines); + self.install(key, map, shapes, outlines); } &self.map } + /// When the map, or the map being built, is for the selection of `cx` but for another revision + /// or address of the document, and the selection's inputs and their outlines are those it is + /// made from, files it under the key of `cx`: nothing it is made from changed. Returns the + /// inputs it computed when that did not file a map (they changed), for the caller to build + /// from. + pub(super) fn rekey(&mut self, cx: &ToolContext, open_filled_as_closed: bool) -> Option<(Vec, Vec)> { + let key = Self::key_for(cx, open_filled_as_closed); + let other = |k: &CacheKey| *k != key && (&k.2, k.3) == (&key.2, key.3); + if self.pending.as_ref().is_some_and(|p| p.key == key) { + return None; + } + let installed = self.key.as_ref().is_some_and(other); + let building = self.pending.as_ref().is_some_and(|p| other(&p.key)); + // The inputs are compared once for each key. + if (!installed && !building) || self.compared.as_ref() == Some(&key) { + return None; + } + self.compared = Some(key.clone()); + let (shapes, outlines) = self.inputs(cx, open_filled_as_closed); + if installed && self.shapes == shapes && self.outlines == outlines { + self.key = Some(key); + return None; + } + if let Some(p) = self.pending.as_mut().filter(|p| other(&p.key) && p.shapes == shapes && p.outlines == outlines) { + p.key = key; + return None; + } + Some((shapes, outlines)) + } + /// Takes the map being built on another thread once it has arrived, or with `wait` once it /// arrives, as the map for the key it was built for. pub(super) fn settle(&mut self, wait: bool) { @@ -152,7 +194,7 @@ impl RegionCache { { self.background += 1; } - self.install(p.key, map, p.outlines); + self.install(p.key, map, p.shapes, p.outlines); } // The build failed (a bug in the planar map): hovering does not build it again, and a // gesture builds it on the calling thread, which reports the failure. @@ -171,21 +213,25 @@ impl RegionCache { if self.key.as_ref() == Some(&key) { return Some(&self.map); } + let inputs = self.rekey(cx, open_filled_as_closed); + if self.key.as_ref() == Some(&key) { + return Some(&self.map); + } if self.pending.is_some() || self.failed.as_ref() == Some(&key) { return None; } - let (shapes, outlines) = self.inputs(cx, open_filled_as_closed); + let (shapes, outlines) = inputs.unwrap_or_else(|| self.inputs(cx, open_filled_as_closed)); let segments: usize = shapes.iter().map(|s| s.path.subpaths.iter().map(|sp| sp.segment_count()).sum::()).sum(); if segments > BACKGROUND_SEGMENTS && cfg!(not(target_arch = "wasm32")) { - if let Some(done) = spawn_build(shapes, self.live, self.gap) { - self.pending = Some(Pending { key, outlines, done }); + if let Some(done) = spawn_build(shapes.clone(), self.live, self.gap) { + self.pending = Some(Pending { key, shapes, outlines, done }); return None; } // No thread started: build here. return Some(self.get(cx, open_filled_as_closed)); } - let map = build_map(shapes, self.live, self.gap); - self.install(key, map, outlines); + let map = build_map(shapes.clone(), self.live, self.gap); + self.install(key, map, shapes, outlines); Some(&self.map) } diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index 667a6e2e3..8a7ba3500 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -132,9 +132,9 @@ impl Tool for LivePaintBucketTool { self.cache.waiting() } - fn poll(&mut self, cx: &ToolContext, wait: bool) { + fn poll(&mut self, cx: &ToolContext, wait: bool) -> Vec { if !self.cache.waiting() { - return; + return vec![]; } // As the Shape Builder's: the map that arrives may be for an earlier revision. Over a part // of a Live Paint group the hover takes no map. @@ -147,6 +147,7 @@ impl Tool for LivePaintBucketTool { break; } } + vec![] } } diff --git a/crates/tools/src/builder/lookup.rs b/crates/tools/src/builder/lookup.rs index 30475be34..221dbfcac 100644 --- a/crates/tools/src/builder/lookup.rs +++ b/crates/tools/src/builder/lookup.rs @@ -256,4 +256,6 @@ pub(super) fn faces_of(regions: Vec) -> Vec { thread_local! { /// The Shape Builder maps built on this thread ([`maps_built`]). pub(super) static MAPS_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// The Shape Builder merges computed on this thread ([`merges_computed`]). + pub(super) static MERGES_COMPUTED: std::cell::Cell = const { std::cell::Cell::new(0) }; } diff --git a/crates/tools/src/builder/map.rs b/crates/tools/src/builder/map.rs index cf9327dd4..65dad0020 100644 --- a/crates/tools/src/builder/map.rs +++ b/crates/tools/src/builder/map.rs @@ -1,4 +1,4 @@ -use super::lookup::{Face, MAPS_BUILT, faces_of, outline_snap, shape_builder_shapes}; +use super::lookup::{Face, MAPS_BUILT, MERGES_COMPUTED, faces_of, node_outline, outline_snap, shape_builder_shapes}; use super::*; /// Shape Builder hover hatch colour (neutral grey) and Live Paint highlight red. @@ -12,6 +12,12 @@ pub fn maps_built() -> usize { MAPS_BUILT.with(std::cell::Cell::get) } +/// How many Shape Builder merges ([`merge_geometry`]) this thread has computed, for tests that +/// count them. +pub fn merges_computed() -> usize { + MERGES_COMPUTED.with(std::cell::Cell::get) +} + /// The Shape Builder's planar map of a selection. #[derive(Default)] pub struct BuilderMap { @@ -338,6 +344,112 @@ impl BuilderMap { let bounds_one = |(e, _): &(po::Shape, Rect)| self.sides(e).iter().any(|r| r.is_some_and(|r| touched.regions.contains(&r))); touched.edges.iter().copied().filter(|&k| self.edges.get(k).is_some_and(|e| !bounds_one(e))).collect() } + + /// What `shapeBuilder.merge` touches: the regions the marquee from `marquee.0` to `marquee.1` + /// reaches, then what the drag along `pts` touches ([`Self::builder_hits_along`]), and the + /// edges that deletes ([`Self::erased_edges`]). + pub fn merge_touched(&self, pts: &[Point], marquee: Option<(Point, Point)>, piece_tol: Option) -> Touched { + let mut touched = Touched { regions: marquee.map(|(a, b)| self.regions_in_marquee(a, b)).unwrap_or_default(), ..Default::default() }; + touched.extend(&self.builder_hits_along(pts, piece_tol)); + touched.edges = self.erased_edges(&touched); + touched + } +} + +/// What a merge needs of each input besides the map: its outline as drawn ([`node_outline`]) and +/// whether its fill closes its open subpaths (Consider Open Filled Paths as Closed, and a fill). +#[derive(Clone, Debug, PartialEq)] +pub struct MergeInput { + pub outline: Option, + pub fill_closes: bool, +} + +/// The [`MergeInput`] of each of `leaves`. +pub fn merge_inputs(leaves: &[&Node], open_filled_as_closed: bool) -> Vec { + leaves + .iter() + .map(|l| MergeInput { outline: node_outline(l).map(|o| o.0), fill_closes: open_filled_as_closed && !l.appearance.fill_paint().is_none() }) + .collect() +} + +/// What is left of an input a merge or a delete changes: its faces not touched, united +/// (`united`), or its own outline, with the deleted edges cut out of it. +#[derive(Clone, Debug, PartialEq)] +pub struct Rest { + pub path: PathData, + pub united: bool, +} + +/// The new shapes of a Shape Builder merge or delete ([`merge_geometry`]). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct MergeGeometry { + /// The union of the touched regions (empty when none is touched). + pub union: PathData, + /// For each closed input, what goes in its place when the gesture changes it. + pub rests: Vec>, +} + +/// The new shapes of a gesture over `map` that touched `touched`, closing gaps up to `gap`: the +/// union of the touched regions, and for each closed input that has a touched region or a deleted +/// edge, what is left of it. An input a touched region covers keeps its other faces, united +/// (they share their edges with the merged faces exactly, where subtracting the merged shape left +/// slivers of the outline along them, #569; a sliver goes in only where the input covers it and +/// the merged shape does not take it in); then each deleted edge is cut out of it. Inputs with +/// pieces of open paths (`map.lines`) are left to the caller. +pub fn merge_geometry(map: &BuilderMap, touched: &Touched, gap: f64, inputs: &[MergeInput]) -> MergeGeometry { + MERGES_COMPUTED.with(|n| n.set(n.get() + 1)); + let cuts: Vec<&SubPath> = touched.edges.iter().filter_map(|&k| map.edges.get(k)?.0.path.subpaths.first()).collect(); + let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect(); + let faces = po::FaceMerger::with_gap(&map.shapes, gap); + let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) }; + let rests = map + .shapes + .iter() + .enumerate() + .map(|(i, sh)| { + let input = inputs.get(i)?; + let in_union = picked.iter().any(|r| r.sources.contains(&i)); + if map.lines.iter().any(|(l, _)| l.key == i as u64) || (!in_union && cuts.is_empty()) { + return None; + } + let mut rest = if in_union { + let kept: Vec<&po::Region> = map + .regions + .iter() + .enumerate() + .filter(|(k, (r, ..))| r.sources.contains(&i) && !touched.regions.contains(k)) + .map(|(_, (r, ..))| r) + .collect(); + if kept.is_empty() { PathData::default() } else { faces.merge_rest(i, &kept, &picked) } + } else { + input.outline.clone().unwrap_or_else(|| sh.path.clone()) + }; + // Edges come back from the planar map within a few hundredths of the outlines they + // follow, whatever else is selected. + let cut_tol = outline_snap(&sh.path); + let mut cut = false; + for piece in &cuts { + if let Some(r) = po::cut_out(&rest, piece, input.fill_closes, cut_tol) { + rest = r; + cut = true; + } + } + (in_union || cut).then_some(Rest { path: rest, united: in_union }) + }) + .collect(); + MergeGeometry { union, rests } +} + +/// A merge computed on another thread for a Shape Builder click or drag +/// ([`Tool::shape_builder_merge`]): the map, what the gesture touched and the inputs it was +/// computed from, and the result. `shapeBuilder.merge` takes the result when it would compute the +/// same. +pub struct PreparedMerge { + pub map: Arc, + pub touched: Touched, + pub gap: f64, + pub inputs: Vec, + pub geometry: MergeGeometry, } /// The regions, line pieces and edges a Shape Builder gesture has touched, in the order touched. diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index a0a6bfb65..49cce1dbc 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -98,20 +98,34 @@ fn shape_builder_builds_its_map_once_for_hover_alt_hover_and_a_merge() { // The merge left the document as it was (it failed, say): the map still fits it. t.pointer(&cx1, &ev(PointerKind::Move, 75.0, 75.0)); assert_eq!(t.cache.builds, 1, "a hover after a merge that changed nothing"); + // A new revision of the same art (a view setting, a preference) keeps the map. let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) }; t.pointer(&cx2, &ev(PointerKind::Move, 75.0, 75.0)); - assert_eq!(t.cache.builds, 2, "a hover on a new revision"); + assert_eq!(t.cache.builds, 1, "a hover on a new revision of the same art"); + assert!(matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover); + // The same objects, one of them moved. + let mut moved = d.clone(); + let second = s.objects[1]; + moved.node_mut(second).unwrap().transform(vectorcraft_geom::Affine::translate((10.0, 0.0)), false); + let cx3 = ToolContext { revision: (1, 3), ..cx(&moved, &s, &p) }; + t.pointer(&cx3, &ev(PointerKind::Move, 75.0, 75.0)); + assert_eq!(t.cache.builds, 2, "a hover on a new revision of other art"); } /// A row of `n` selected squares 20 pt wide, each overlapping the next by half: 4 segments each. fn square_row(n: usize) -> (Document, Selection) { + square_row_at(n, 0.0) +} + +/// [`square_row`] with the squares' tops at `y`: the same objects, moved. +fn square_row_at(n: usize, y: f64) -> (Document, Selection) { let mut d = Document::new(500.0, 500.0); let l = d.layers[0].id; let mut s = Selection::default(); for k in 0..n { let x = k as f64 * 10.0; let id = d.alloc_id(); - d.insert(Some(l), k, Node::path(id, shapes::rectangle(Rect::new(x, 0.0, x + 20.0, 20.0)), Appearance::default_art())).unwrap(); + d.insert(Some(l), k, Node::path(id, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), Appearance::default_art())).unwrap(); s.add(id); } (d, s) @@ -142,9 +156,10 @@ fn shape_builder_hover_builds_a_large_map_on_another_thread() { assert_eq!((t.cache.builds, t.cache.background), (1, 1)); assert_eq!(t.overlays(&cx1).len(), 2, "the region's hatch and outline"); // A new revision while the map of the one before it is being built: only the newer one's - // map is used. - let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) }; - let cx3 = ToolContext { revision: (1, 3), ..cx(&d, &s, &p) }; + // map is used. Each revision moves the squares down 1 pt. + let rows: Vec = (0..7).map(|k| square_row_at(40, f64::from(k)).0).collect(); + let cx2 = ToolContext { revision: (1, 2), ..cx(&rows[2], &s, &p) }; + let cx3 = ToolContext { revision: (1, 3), ..cx(&rows[3], &s, &p) }; t.pointer(&cx2, &hover); t.pointer(&cx3, &hover); poll_until_ready(&mut t, &cx3); @@ -152,29 +167,235 @@ fn shape_builder_hover_builds_a_large_map_on_another_thread() { assert_eq!((t.cache.builds, t.cache.background), (3, 3), "the earlier revision's map, then the newer one's"); assert_eq!(t.cache.key.as_ref().map(|k| k.1), Some((1, 3))); // A host without frames waits for the map. - let cx4 = ToolContext { revision: (1, 4), ..cx(&d, &s, &p) }; + let cx4 = ToolContext { revision: (1, 4), ..cx(&rows[4], &s, &p) }; t.pointer(&cx4, &hover); assert!(t.waiting() && t.hover.is_none()); t.poll(&cx4, true); assert!(!t.waiting()); assert!(matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover); assert_eq!((t.cache.builds, t.cache.background), (4, 4)); - // A press while the map is being built waits for it, and the drag merges. - let cx5 = ToolContext { revision: (1, 5), ..cx(&d, &s, &p) }; + // A drag while the map is being built is replayed once it is there, and merges (on + // another thread, see `shape_builder_merges_a_large_selection_on_another_thread` and + // `shape_builder_gesture_waits_for_the_map_without_holding_the_caller`). + let cx5 = ToolContext { revision: (1, 5), ..cx(&rows[5], &s, &p) }; t.pointer(&cx5, &hover); t.pointer(&cx5, &ev(PointerKind::Down, 15.0, 10.0)); t.pointer(&cx5, &ev(PointerKind::Drag, 35.0, 10.0)); - let acts = t.pointer(&cx5, &ev(PointerKind::Up, 35.0, 10.0)); - assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{acts:?}"); + assert!(t.pointer(&cx5, &ev(PointerKind::Up, 35.0, 10.0)).is_empty()); + let acts = t.poll(&cx5, true); assert_eq!((t.cache.builds, t.cache.background), (5, 5)); + assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{acts:?}"); assert!(!t.waiting()); // Changing an option forgets a map being built with the old one. - let cx6 = ToolContext { revision: (1, 6), ..cx(&d, &s, &p) }; + let cx6 = ToolContext { revision: (1, 6), ..cx(&rows[6], &s, &p) }; t.pointer(&cx6, &hover); t.set_option("gapDetection", &json!(true)); assert!(!t.waiting()); } +/// Polls `t` until it stops waiting, as the desktop app does each frame, and returns the +/// actions the polls gave. +fn poll_actions(t: &mut dyn Tool, cx: &ToolContext) -> Vec { + let start = std::time::Instant::now(); + let mut acts = vec![]; + while t.waiting() { + assert!(start.elapsed() < std::time::Duration::from_secs(60), "the tool is still waiting"); + std::thread::sleep(std::time::Duration::from_millis(2)); + acts.extend(t.poll(cx, false)); + } + acts +} + +/// A Shape Builder over `square_row(40)` (160 segments) that has merged nothing, with its map, +/// after a click at (15, 10) that started a merge on another thread. +fn clicked_row(cx: &ToolContext) -> ShapeBuilderTool { + let mut t = ShapeBuilderTool::default(); + t.pointer(cx, &ev(PointerKind::Move, 15.0, 10.0)); + poll_until_ready(&mut t, cx); + t.pointer(cx, &ev(PointerKind::Down, 15.0, 10.0)); + assert!(t.pointer(cx, &ev(PointerKind::Up, 15.0, 10.0)).is_empty(), "the release returns before the merge is computed"); + assert!(t.waiting() && t.editing()); + t +} + +#[test] +fn shape_builder_merges_a_large_selection_on_another_thread() { + let (d, s) = square_row(40); + let p = paint(); + let cx = cx(&d, &s, &p); + let computed = merges_computed(); + let mut t = clicked_row(&cx); + assert_eq!(t.cursor(&cx, Point::new(15.0, 10.0), Mods::default()), Cursor::Busy); + assert_eq!(t.overlays(&cx).len(), 2, "the clicked region's hatch and outline stay"); + // A press while the merge is being computed starts no gesture. + assert!(t.pointer(&cx, &ev(PointerKind::Down, 35.0, 10.0)).is_empty()); + assert!(!t.busy()); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 35.0, 10.0)).is_empty()); + let acts = poll_actions(&mut t, &cx); + assert_eq!(merges_computed(), computed, "the merge was computed on another thread"); + let [Action::Exec(id, params)] = acts.as_slice() else { panic!("{acts:?}") }; + assert_eq!((id.as_str(), ¶ms["points"]), ("shapeBuilder.merge", &json!([[15.0, 10.0]]))); + assert!(!t.waiting() && !t.editing()); + assert_eq!(t.cursor(&cx, Point::new(15.0, 10.0), Mods::default()), Cursor::ShapeBuilder); + let m = t.shape_builder_merge().expect("the merge the command takes"); + assert!(Arc::ptr_eq(&m.map, &t.cache.map)); + assert_eq!((m.touched.regions.len(), m.touched.lines.len(), m.touched.edges.len()), (1, 0, 0)); + assert!(!m.geometry.union.is_empty()); + assert_eq!(m.geometry, merge_geometry(&m.map, &m.touched, m.gap, &m.inputs)); + assert_eq!(m.geometry.rests.iter().flatten().count(), 2, "the two squares over the region change"); + // The next event lets it go. + t.pointer(&cx, &ev(PointerKind::Move, 15.0, 10.0)); + assert!(t.shape_builder_merge().is_none()); +} + +#[test] +fn shape_builder_drops_a_merge_the_art_changed_under() { + let (d, s) = square_row(40); + let p = paint(); + let mut fewer = Selection::default(); + fewer.set(s.objects.iter().skip(1).copied()); + let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) }; + let (moved, _) = square_row_at(40, 5.0); + let later = [ + // An edit or an undo: the squares moved. + ToolContext { revision: (1, 2), ..cx(&moved, &s, &p) }, + // Another document. + ToolContext { revision: (2, 1), ..cx(&d, &s, &p) }, + // Another selection. + ToolContext { revision: (1, 1), ..cx(&d, &fewer, &p) }, + ]; + for (k, cx2) in later.iter().enumerate() { + let mut t = clicked_row(&cx1); + // Dropped at the first poll, before the thread is done. + assert!(t.poll(cx2, false).is_empty(), "change {k}: the merge is not applied"); + assert!(!t.editing() && !t.waiting() && t.shape_builder_merge().is_none(), "change {k}"); + } + // Unchanged, the same wait applies it. + let mut t = clicked_row(&cx1); + assert_eq!(t.poll(&cx1, true).len(), 1); + // A copy of the document at the same revision (a save stamps its dates on a copy), and + // finishing the edit before a command, apply it too. + let copy = d.clone(); + let cx3 = ToolContext { revision: (1, 1), ..cx(©, &s, &p) }; + let mut t = clicked_row(&cx1); + assert_eq!(t.finish_edit(&cx3).len(), 1); + assert!(!t.editing() && t.shape_builder_merge().is_some()); + // Switching away applies it. + let mut t = clicked_row(&cx1); + assert_eq!(t.deactivate(&cx1).len(), 1); + assert!(!t.editing()); +} + +/// A new revision of the document that leaves the selection's inputs as they were (a view +/// setting or a preference that redraws it) keeps the merge being computed, the gesture made +/// while the map is being built and the map itself: the merge applies with the map it was +/// computed over, and no map is built again. +#[test] +fn shape_builder_keeps_its_work_over_a_new_revision_of_the_same_art() { + let (d, s) = square_row(40); + let p = paint(); + let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) }; + let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) }; + // A merge being computed. + let computed = merges_computed(); + let mut t = clicked_row(&cx1); + let acts = poll_actions(&mut t, &cx2); + assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{acts:?}"); + assert_eq!(merges_computed(), computed, "the merge computed on another thread"); + let m = t.shape_builder_merge().expect("the merge the command takes"); + assert!(Arc::ptr_eq(&m.map, &t.cache.map), "the map the merge was computed over"); + assert_eq!(t.cache.key.as_ref().map(|k| k.1), Some((1, 2))); + assert_eq!(t.cache.builds, 1, "no map built again"); + // Finishing it before a command too. + let mut t = clicked_row(&cx1); + assert_eq!(t.finish_edit(&cx2).len(), 1); + // A hover over the new revision builds no map. + let mut t = ShapeBuilderTool::default(); + let hover = ev(PointerKind::Move, 15.0, 10.0); + t.pointer(&cx1, &hover); + poll_until_ready(&mut t, &cx1); + t.pointer(&cx2, &hover); + assert!(!t.waiting() && matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover); + assert_eq!(t.cache.builds, 1); + // A map being built, and a gesture made meanwhile. + let drag = [ev(PointerKind::Down, 15.0, 10.0), ev(PointerKind::Drag, 35.0, 10.0), ev(PointerKind::Up, 35.0, 10.0)]; + let mut ready = ShapeBuilderTool::default(); + ready.pointer(&cx1, &hover); + poll_until_ready(&mut ready, &cx1); + for e in &drag { + ready.pointer(&cx1, e); + } + let want = poll_actions(&mut ready, &cx1); + let mut t = ShapeBuilderTool::default(); + t.pointer(&cx1, &hover); + for e in &drag { + assert!(t.pointer(&cx1, e).is_empty()); + } + assert!(t.editing() && t.cache.builds == 0, "the gesture waits for the map"); + assert_eq!(poll_actions(&mut t, &cx2), want); + assert_eq!((t.cache.builds, t.cache.background), (1, 1), "the map built for the first revision"); + // Finishing it before a command waits for the map being built rather than building it again. + let mut t = ShapeBuilderTool::default(); + t.pointer(&cx1, &hover); + for e in &drag { + t.pointer(&cx1, e); + } + assert_eq!(t.finish_edit(&cx2), want); + assert_eq!((t.cache.builds, t.cache.background), (1, 1), "the map built on the other thread"); + // Another document with the same art drops the merge. + let cx3 = ToolContext { revision: (2, 1), ..cx(&d, &s, &p) }; + let mut t = clicked_row(&cx1); + assert!(t.poll(&cx3, false).is_empty()); + assert!(!t.editing()); +} + +/// A press while the map of a large selection is being built on another thread does not wait +/// for it: the gesture is recorded, each event returns at once, and the polls replay it once +/// the map is there, giving the merge the same gesture gives over the map. Finishing the edit +/// waits for the map and the merge; another revision of other art drops the gesture. +#[test] +fn shape_builder_gesture_waits_for_the_map_without_holding_the_caller() { + let (d, s) = square_row(200); + let p = paint(); + let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) }; + let hover = ev(PointerKind::Move, 15.0, 10.0); + let drag = [ev(PointerKind::Down, 15.0, 10.0), ev(PointerKind::Drag, 35.0, 10.0), ev(PointerKind::Up, 35.0, 10.0)]; + let mut ready = ShapeBuilderTool::default(); + ready.pointer(&cx1, &hover); + poll_until_ready(&mut ready, &cx1); + for e in &drag { + ready.pointer(&cx1, e); + } + let want = poll_actions(&mut ready, &cx1); + assert!(matches!(want.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{want:?}"); + let made_while_building = || { + let mut t = ShapeBuilderTool::default(); + t.pointer(&cx1, &hover); + for e in &drag { + assert!(t.pointer(&cx1, e).is_empty()); + } + assert_eq!(t.cache.builds, 0, "the gesture did not wait for the map"); + assert!(t.editing() && t.waiting()); + t + }; + let mut t = made_while_building(); + assert_eq!(t.cursor(&cx1, Point::new(35.0, 10.0), Mods::default()), Cursor::Busy); + // A press before the gesture is replayed starts nothing. + assert!(t.pointer(&cx1, &ev(PointerKind::Down, 55.0, 10.0)).is_empty()); + assert!(t.pointer(&cx1, &ev(PointerKind::Up, 55.0, 10.0)).is_empty()); + assert_eq!(poll_actions(&mut t, &cx1), want); + assert!(!t.editing() && t.shape_builder_merge().is_some()); + // Finishing the edit before a command waits for both. + let mut t = made_while_building(); + assert_eq!(t.finish_edit(&cx1), want); + // Another revision of other art before the map is there drops the gesture. + let mut t = made_while_building(); + let (moved, _) = square_row_at(200, 5.0); + let cx2 = ToolContext { revision: (1, 2), ..cx(&moved, &s, &p) }; + assert!(t.poll(&cx2, false).is_empty()); + assert!(!t.editing()); +} + #[test] fn shape_builder_hover_builds_a_small_map_at_once() { let (d, s) = square_row(3); @@ -775,9 +996,15 @@ fn bucket_keeps_its_map_while_the_document_is_unchanged() { // The fill left the document as it was: the map still fits it. t.pointer(&cx1, &ev(PointerKind::Move, 25.0, 50.0)); assert_eq!(t.cache.builds, 1, "a hover after a fill that changed nothing"); + // A new revision of the same art keeps the map; one with an object moved builds it again. let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) }; t.pointer(&cx2, &ev(PointerKind::Move, 25.0, 50.0)); - assert_eq!(t.cache.builds, 2, "a hover on a new revision"); + assert_eq!(t.cache.builds, 1, "a hover on a new revision of the same art"); + let mut moved = d.clone(); + moved.node_mut(b).unwrap().transform(vectorcraft_geom::Affine::translate((10.0, 0.0)), false); + let cx3 = ToolContext { revision: (1, 3), ..cx(&moved, &s, &p) }; + t.pointer(&cx3, &ev(PointerKind::Move, 25.0, 50.0)); + assert_eq!(t.cache.builds, 2, "a hover on a new revision of other art"); } #[test] diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index e55bb4069..9557e0ac1 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -243,21 +243,41 @@ pub trait Tool: Send { false } /// Is the tool waiting for work it runs on another thread (the planar map of a large selection - /// for the Shape Builder and the Live Paint Bucket)? It highlights no region under the pointer - /// until the work is done. While this holds, the desktop app calls [`Tool::poll`] each frame, - /// and hosts without frames call it with `wait` after a [`PointerKind::Move`]. + /// for the Shape Builder and the Live Paint Bucket, a Shape Builder merge of one)? It + /// highlights nothing under the pointer until a map is done. While this holds, the desktop app + /// calls [`Tool::poll`] each frame, and hosts without frames call it with `wait` after each + /// pointer event. fn waiting(&self) -> bool { false } - /// Checks on the work [`Tool::waiting`] reports, waiting for it to finish with `wait`. Once it - /// is done, the tool shows the highlight under the pointer where it last moved. - fn poll(&mut self, _cx: &ToolContext, _wait: bool) {} + /// Is the tool computing an edit on another thread, which [`Tool::poll`] applies once it is + /// done (a Shape Builder merge of a large selection)? + fn editing(&self) -> bool { + false + } + /// Checks on the work [`Tool::waiting`] reports, waiting for it to finish with `wait`. Once a + /// map is done, the tool shows the highlight under the pointer where it last moved; once an + /// edit is done, the actions returned apply it. + fn poll(&mut self, _cx: &ToolContext, _wait: bool) -> Vec { + vec![] + } + /// Waits for the edit [`Tool::editing`] reports and returns the actions that apply it, before + /// a command runs on the document; none when the document's revision or the selection changed + /// since the gesture. + fn finish_edit(&mut self, _cx: &ToolContext) -> Vec { + vec![] + } /// The Shape Builder's planar map of the selection, with the outline edges, and the gap it /// closes, when the tool has one. `shapeBuilder.merge` uses it in place of a map of its own /// when the map's inputs are the merge's inputs. fn shape_builder_map(&self) -> Option<(std::sync::Arc, f64)> { None } + /// The Shape Builder merge the tool computed on another thread for its last gesture, which + /// `shapeBuilder.merge` takes when it would compute the same ([`builder::PreparedMerge`]). + fn shape_builder_merge(&self) -> Option> { + None + } } /// Create a tool by id. Unknown or not-yet-implemented tools fall back to a no-op tool that keeps the id. diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 86b75c03d..834f5b4ec 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -80,6 +80,7 @@ impl VectorcraftApp { pending_url: None, control_rx: None, pending_screenshots: vec![], + pointer_replies: vec![], queued_screenshots: vec![], screenshot_token: 0, screenshot_reserved: 0, @@ -293,8 +294,9 @@ impl VectorcraftApp { ctx.request_repaint_after(std::time::Duration::from_millis(100)); } // The window's close button (or the system quitting the app) asks about unsaved documents, - // once the saves running in the background are done. Not while a file dialog's sheet is on - // the window: what asked for it runs again when it answers. + // once the saves running in the background are done and a Shape Builder merge being + // computed is applied. Not while a file dialog's sheet is on the window: what asked for it + // runs again when it answers. if ctx.input(|i| i.viewport().close_requested()) { // This native viewport is going away: release its IME context. No request is // allocated here: the close prompt or file sheet publishes no custom descriptor, so a @@ -302,7 +304,7 @@ impl VectorcraftApp { // fresh generation on the next pass (never inherits a composition across the prompt). crate::ime::close_viewport(self, ctx); self.ime_context.end_viewport(ctx.viewport_id()); - background::wait_all(self); + unsaved::finish_pending(self); if self.file_sheet_open() { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); self.status(picks::busy()); @@ -327,6 +329,7 @@ impl VectorcraftApp { // system title bar this is where the document name lives, as the in-app mark is hidden. crate::chrome::sync_window_title(self, ctx); self.drain_control(ctx); + self.answer_pointer_replies(ctx); // An egui `PasteImage` with a rich system clipboard: run the paste command now, once. It // goes through `run`, whose `edit.paste` branch calls `adopt_system_clipboard` (which clears // the request) to read the formats and then pastes them. A text paste in the same UI pass diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index b31f42d85..993a87a05 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -21,6 +21,14 @@ impl VectorcraftApp { if cfg!(debug_assertions) { menus::check_params(&self.session, id, ¶ms)?; } + // A command that waits for the edit the tool computes on another thread and the input held + // for it waits before the app adds the view's active artboard to its params: a held press + // may change it. The app's own commands (its dialogs, panels and view) wait too. + let ui_command = vectorcraft_engine::find_command(id).is_none(); + if (ui_command || vectorcraft_engine::waits_for_tool_edit(id)) && self.session.tool_waiting() { + let r = self.session.finish_tool_edit(); + canvas::apply_requests(self, r); + } let artboards = self.artboard_tool_state(); if let Some(r) = menus::run_ui_command(self, id, ¶ms) { self.follow_artboard_tool(artboards); diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 13eca92db..479f911b5 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -85,6 +85,7 @@ impl VectorcraftApp { } } } + control::Outcome::AfterToolEdit { from, to } => self.pointer_replies.push((from, to, reply)), } } if handled == control::MAX_CONTROL_REQUESTS_PER_FRAME { @@ -94,6 +95,37 @@ impl VectorcraftApp { self.control_rx = Some(rx); } + /// Sends the `ui.pointer` replies that wait for the tool's edit once their events have been + /// made and the edit is applied. The canvas checks on the tool each frame it is drawn; this + /// checks too, for frames without it. + pub(crate) fn answer_pointer_replies(&mut self, ctx: &egui::Context) { + // Those whose events the canvas's check made go first, before this check makes the input + // held after them. + self.send_pointer_replies(); + if self.pointer_replies.is_empty() { + return; + } + if self.session.tool_editing() { + let r = self.session.tool_poll(self.view_info(), false); + canvas::apply_requests(self, r); + } + self.send_pointer_replies(); + if !self.pointer_replies.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + } + + /// Sends the waiting `ui.pointer` replies whose events have been made. + fn send_pointer_replies(&mut self) { + for (from, to, reply) in std::mem::take(&mut self.pointer_replies) { + if self.session.input_applied(from, to) { + let _ = reply.send(control::pointer_reply(self)); + } else { + self.pointer_replies.push((from, to, reply)); + } + } + } + /// The active document's identity: its process-unique uid and revision. A capture records it /// at admission and issuance and refuses to answer with a different document or undo state. pub(super) fn document_identity(&self) -> Option<(u64, u64)> { diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs index e17690dd3..5d6b37475 100644 --- a/crates/ui-egui/src/canvas/drag.rs +++ b/crates/ui-egui/src/canvas/drag.rs @@ -251,19 +251,29 @@ pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_e { v.artboard = i; } - // A press with a selection tool inside an artboard makes it the active one (#693), as the - // Artboard tool's does: Paste in Place and in Front or Back then paste onto it, Align to - // Artboard aligns to it, and All on Active Artboard selects on it. - if ev.kind == PointerKind::Down - && vectorcraft_tools::catalog::is_selection_tool(app.session.tool_id()) - && let Some(i) = app.session.active().and_then(|d| d.doc.artboard_at(ev.pos)) - && let Some(v) = app.view_mut() - { - v.artboard = i; + if ev.kind == PointerKind::Down { + pressed_with(app, app.session.active_index(), ev.pos, app.session.tool_id()); } apply_requests(app, r); } +/// A press at `pos` in document `index` with tool `tool` (a selection tool) inside an artboard +/// makes it that document's active one (#693), as the Artboard tool's does: Paste in Place and in +/// Front or Back then paste onto it, Align to Artboard aligns to it, and All on Active Artboard +/// selects on it. +fn pressed_with(app: &mut VectorcraftApp, index: Option, pos: Point, tool: &str) { + if !vectorcraft_tools::catalog::is_selection_tool(tool) { + return; + } + app.sync_views(); + if let Some(i) = index + && let Some(board) = app.session.documents().get(i).and_then(|d| d.doc.artboard_at(pos)) + && let Some(v) = app.views.get_mut(i) + { + v.artboard = board; + } +} + /// Act on what a tool event asked the UI for (dialogs, tool switches), or show its error. pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result>) { match r { @@ -274,6 +284,11 @@ pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result app.select_tool(&t), vectorcraft_engine::UiRequest::Status(msg) => app.status(msg), vectorcraft_engine::UiRequest::Place(p) => crate::place::place_from_cursor(app, &p), + // In the document the press was made in, which may no longer be the active one. + vectorcraft_engine::UiRequest::HeldPress { doc, pos, tool } => { + let index = doc.and_then(|uid| app.session.documents().iter().position(|d| d.uid == uid)); + pressed_with(app, index, pos, tool); + } } } } diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 319e9c84b..b597e5a0a 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -354,12 +354,12 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo app.session.tool_hover_ended(view); ui.ctx().request_repaint(); } - // The tool works on another thread (a large selection's planar map): it checks each frame - // whether the work is done, and then shows the highlight under the pointer. + // The tool works on another thread (a large selection's planar map or merge): it checks each + // frame whether the work is done, and then shows the highlight under the pointer or applies + // the edit. if app.session.tool_waiting() { - if let Err(e) = app.session.tool_poll(view, false) { - app.status(e.to_string()); - } + let r = app.session.tool_poll(view, false); + apply_requests(app, r); ui.ctx().request_repaint_after(std::time::Duration::from_millis(16)); } if pointer.primary_released() diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index cd6efdf7c..4cca632a7 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -145,6 +145,21 @@ pub enum Outcome { path: Option, data: bool, }, + /// A `ui.pointer` reply ([`pointer_reply`]), sent once the events numbered `from..to` + /// ([`vectorcraft_engine::Session::input_applied`]) have been made and the edit they started + /// on another thread is applied, so it shows the selection after the edit. + AfterToolEdit { + from: u64, + to: u64, + }, +} + +/// The reply to `ui.pointer`: the selection, the key object and the active tool. +pub(crate) fn pointer_reply(app: &mut VectorcraftApp) -> Value { + match app.run("document.inspect", json!({})) { + Ok(d) => json!({"ok": true, "result": {"selection": d["selection"], "key": d["key"], "tool": app.session.tool_id()}}), + Err(e) => json!({"ok": false, "error": e.to_string()}), + } } fn ok(v: Value) -> Outcome { @@ -264,6 +279,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default(); let view = app.view_info(); let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v)); + let from = app.session.input_count(); for e in events { let kind = match e.get("kind").and_then(Value::as_str).unwrap_or("") { "down" => PointerKind::Down, @@ -292,7 +308,15 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques } } ctx.request_repaint(); - wrap(app.run("document.inspect", json!({})).map(|d| json!({"selection": d["selection"], "key": d["key"], "tool": app.session.tool_id()}))) + // A Shape Builder merge of a large selection is computed on another thread, and the + // reply to the events that started it waits for it without holding the frames up. A + // gesture sent while it is computed is made after it, and its reply waits until then; + // a hover is answered at once. + let to = app.session.input_count(); + if !app.session.input_applied(from, to) { + return Outcome::AfterToolEdit { from, to }; + } + Outcome::Done(pointer_reply(app)) } "ui.key" => { let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") }; diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index f24c958c4..d0decf73a 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -1,5 +1,6 @@ use serde_json::{Value, json}; use vectorcraft_engine::Session; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; use super::{ControlRequest, MAX_DIALOG_FIELDS, Outcome, handle}; use crate::state::Dialog; @@ -249,6 +250,242 @@ fn pointer_replies_name_the_key_object() { assert_eq!(click(&mut app), Some(Value::Null), "a click on the key again lets it go"); } +/// A Shape Builder click on a large selection merges on another thread: the frames go on, and +/// the `ui.pointer` reply comes once the merge is applied, with the selection after it. +#[test] +fn pointer_reply_waits_for_a_merge_on_another_thread() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Services::default()).with_control(rx); + app.run("file.new", json!({"width": 800, "height": 200})).unwrap(); + for k in 0..40 { + app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap(); + } + app.run("select.all", json!({})).unwrap(); + app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap(); + let ctx = egui::Context::default(); + let send = |app: &mut VectorcraftApp, events: Value| { + let (req, reply) = ControlRequest::new("ui.pointer", json!({"events": events})); + tx.send(req).unwrap(); + app.drain_control(&ctx); + reply + }; + let hover = send(&mut app, json!([{"kind": "move", "x": 15, "y": 10}])); + assert_eq!(hover.try_recv().unwrap()["ok"], true, "a hover is answered at once"); + app.session.tool_poll(app.view_info(), true).unwrap(); + let revision = app.session.active().unwrap().revision; + let click = send(&mut app, json!([{"kind": "down", "x": 15, "y": 10}, {"kind": "up", "x": 15, "y": 10}])); + assert!(click.try_recv().is_err() && app.session.tool_editing(), "the click is not answered before the merge is applied"); + // A hover sent meanwhile is answered at once; a click is made after the merge, and its + // reply waits until then. + let hover = send(&mut app, json!([{"kind": "move", "x": 35, "y": 10}])); + assert_eq!(hover.try_recv().map(|r| r["ok"].clone()), Ok(json!(true)), "a hover during the merge"); + let cmd = json!({"cmd": true}); + let press = send(&mut app, json!([{"kind": "down", "x": 500, "y": 150, "mods": cmd}, {"kind": "up", "x": 500, "y": 150, "mods": cmd}])); + assert!(press.try_recv().is_err(), "a Cmd-click during the merge waits for it"); + let hover = send(&mut app, json!([{"kind": "move", "x": 45, "y": 10}])); + assert_eq!(hover.try_recv().map(|r| r["ok"].clone()), Ok(json!(true)), "a hover after the held Cmd-click"); + let start = std::time::Instant::now(); + let reply = loop { + if let Ok(r) = click.try_recv() { + break r; + } + assert!(app.session.tool_editing(), "the merge is being computed until the reply goes"); + assert_eq!(app.session.active().unwrap().revision, revision); + assert!(start.elapsed() < std::time::Duration::from_secs(60), "no reply"); + std::thread::sleep(std::time::Duration::from_millis(2)); + // A frame: the canvas checks on the tool, then the replies are answered. + let r = app.session.tool_poll(app.view_info(), false); + crate::canvas::apply_requests(&mut app, r); + app.answer_pointer_replies(&ctx); + }; + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(reply["result"]["selection"].as_array().map(Vec::len), Some(41), "{reply}: the selection after the merge"); + assert_ne!(app.session.active().unwrap().revision, revision); + let press = loop { + if let Ok(r) = press.try_recv() { + break r; + } + assert!(start.elapsed() < std::time::Duration::from_secs(60), "no reply to the Cmd-click"); + let r = app.session.tool_poll(app.view_info(), false); + crate::canvas::apply_requests(&mut app, r); + app.answer_pointer_replies(&ctx); + }; + assert_eq!(press["result"]["selection"].as_array().map(Vec::len), Some(0), "{press}: the lent selection tool deselected after the merge"); + assert_eq!(press["result"]["tool"], "shapeBuilder", "{press}"); +} + +/// A Cmd-click held while a Shape Builder merge is computed on another thread makes the artboard +/// it lands in active once it is made, as a Cmd-click made at once does: by the frames' checks, +/// by choosing another tool first, or before a command that acts on the active artboard takes +/// it. +#[test] +fn a_held_cmd_click_makes_its_artboard_active() { + for then in ["frames", "another tool", "All on Active Artboard", "another document"] { + held_cmd_click(then); + } +} + +fn held_cmd_click(then: &str) { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + if then == "another document" { + // A document with an artboard where the press is, whose view keeps its own. + app.run("file.new", json!({"width": 1400, "height": 200})).unwrap(); + app.run("artboard.new", json!({"x": 1000, "y": 0, "width": 300, "height": 200})).unwrap(); + app.view_mut().unwrap().artboard = 0; + } + app.run("file.new", json!({"width": 800, "height": 200})).unwrap(); + app.run("artboard.new", json!({"x": 1000, "y": 0, "width": 300, "height": 200})).unwrap(); + for k in 0..40 { + app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap(); + } + app.run("select.all", json!({})).unwrap(); + app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap(); + app.view_mut().unwrap().artboard = 0; + let view = app.view_info(); + let at = |kind, x, mods| PointerEvent { kind, pos: vectorcraft_geom::Point::new(x, 10.0), mods, pressure: 1.0 }; + crate::canvas::dispatch(&mut app, &at(PointerKind::Move, 15.0, Mods::default()), view); + app.session.tool_poll(view, true).unwrap(); + crate::canvas::dispatch(&mut app, &at(PointerKind::Down, 15.0, Mods::default()), view); + crate::canvas::dispatch(&mut app, &at(PointerKind::Up, 15.0, Mods::default()), view); + let cmd = Mods { cmd: true, ..Default::default() }; + crate::canvas::dispatch(&mut app, &at(PointerKind::Down, 1100.0, cmd), view); + crate::canvas::dispatch(&mut app, &at(PointerKind::Up, 1100.0, cmd), view); + assert_eq!(app.view().unwrap().artboard, 0, "{then}: the Cmd-click is held"); + if then == "another tool" { + app.select_tool("rectangle"); + assert!(!app.session.tool_editing(), "{then}"); + } + if then == "All on Active Artboard" { + app.run("select.allOnArtboard", json!({})).unwrap(); + assert!(app.session.doc().unwrap().selection.is_empty(), "{then}: the second artboard, which holds no art"); + } + let doc = app.session.active_index().unwrap(); + if then == "another document" { + // A document tab chosen. + app.session.set_active(0); + } + while app.session.tool_waiting() { + let r = app.session.tool_poll(view, true); + crate::canvas::apply_requests(&mut app, r); + } + app.sync_views(); + assert_eq!(app.views[doc].artboard, 1, "{then}: the Cmd-click made the artboard it landed in active"); + if then == "another document" { + assert_eq!(app.views[0].artboard, 0, "{then}: the other document's view keeps its artboard"); + } +} + +/// A dialog chosen from the menu, or with `ui.menuDialog`, while a Shape Builder merge is +/// computed on another thread opens after the merge is applied, on the merged art. So do the +/// app's own dialogs (Recolor Artwork), from the menu or a panel's button. +#[test] +fn menu_dialogs_open_after_a_merge_on_another_thread() { + for route in ["menu", "ui.menuDialog", "app dialog from the menu", "app dialog from a panel"] { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 800, "height": 200})).unwrap(); + for k in 0..40 { + app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap(); + } + app.run("select.all", json!({})).unwrap(); + app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap(); + let view = app.view_info(); + let at = |kind| PointerEvent { kind, pos: vectorcraft_geom::Point::new(15.0, 10.0), mods: Mods::default(), pressure: 1.0 }; + crate::canvas::dispatch(&mut app, &at(PointerKind::Move), view); + app.session.tool_poll(view, true).unwrap(); + crate::canvas::dispatch(&mut app, &at(PointerKind::Down), view); + crate::canvas::dispatch(&mut app, &at(PointerKind::Up), view); + assert!(app.session.tool_editing(), "{route}"); + match route { + "menu" => crate::menus::invoke(&mut app, "object.move", json!({})), + "ui.menuDialog" => { + app.run("ui.menuDialog", json!({"command": "object.move"})).unwrap(); + } + "app dialog from the menu" => crate::menus::invoke(&mut app, "ui.recolorDialog", json!({})), + _ => { + app.run("ui.recolorDialog", json!({})).unwrap(); + } + } + assert!(app.ui.dialog.is_some(), "{route}: the dialog is open"); + assert!(!app.session.tool_editing(), "{route}"); + let merged = app.session.doc().unwrap().history.undo.last().map(|e| e.label.clone()); + assert_eq!(merged.as_deref(), Some("Shape Builder"), "{route}"); + assert_eq!(app.session.doc().unwrap().selection.objects.len(), 41, "{route}: the dialog acts on the merged art"); + } +} + +/// The Separations panel's checkboxes and a preference set while a Shape Builder merge is +/// computed on another thread give the documents new revisions: the merge is applied first, +/// and the document keeps it. +#[test] +fn panels_that_redraw_apply_a_merge_on_another_thread_first() { + let cases = [ + ("view.separationsPreview", json!({"on": false})), + ("view.overprintPreview", json!({"on": false})), + ("prefs.set", json!({"values": {"gridlineEvery": 90}})), + ]; + for (id, params) in cases { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 800, "height": 200})).unwrap(); + for k in 0..40 { + app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap(); + } + app.run("select.all", json!({})).unwrap(); + app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap(); + let view = app.view_info(); + let at = |kind| PointerEvent { kind, pos: vectorcraft_geom::Point::new(15.0, 10.0), mods: Mods::default(), pressure: 1.0 }; + crate::canvas::dispatch(&mut app, &at(PointerKind::Move), view); + app.session.tool_poll(view, true).unwrap(); + crate::canvas::dispatch(&mut app, &at(PointerKind::Down), view); + crate::canvas::dispatch(&mut app, &at(PointerKind::Up), view); + assert!(app.session.tool_editing(), "{id}"); + app.run(id, params).unwrap(); + assert!(!app.session.tool_editing(), "{id}"); + for _ in 0..3 { + app.session.tool_poll(view, false).unwrap(); + } + let merged = app.session.doc().unwrap().history.undo.last().map(|e| e.label.clone()); + assert_eq!(merged.as_deref(), Some("Shape Builder"), "{id}"); + assert_eq!(app.session.doc().unwrap().selection.objects.len(), 41, "{id}"); + } +} + +/// Saving, exporting or closing while a Shape Builder merge is computed on another thread +/// applies the merge first: the file holds it, and closing asks to save it. +#[test] +fn save_and_close_apply_a_merge_on_another_thread_first() { + let dir = vectorcraft_testkit::temp_dir("control-merge-save"); + for then in ["file.saveAs", "file.exportAs", "file.close"] { + let services = Services { write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), ..Default::default() }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 800, "height": 200})).unwrap(); + for k in 0..40 { + app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap(); + } + app.run("select.all", json!({})).unwrap(); + app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap(); + // As if just saved: only the merge modifies it. + app.session.doc_mut().unwrap().mark_saved(); + let view = app.view_info(); + let at = |kind| PointerEvent { kind, pos: vectorcraft_geom::Point::new(15.0, 10.0), mods: Mods::default(), pressure: 1.0 }; + crate::canvas::dispatch(&mut app, &at(PointerKind::Move), view); + app.session.tool_poll(view, true).unwrap(); + crate::canvas::dispatch(&mut app, &at(PointerKind::Down), view); + crate::canvas::dispatch(&mut app, &at(PointerKind::Up), view); + assert!(app.session.tool_editing() && !app.session.doc().unwrap().is_dirty(), "{then}"); + let path = dir.join(if then == "file.exportAs" { "a.svg" } else { "a.vectorcraft" }); + let p = if then == "file.close" { json!({}) } else { json!({"path": path.to_string_lossy()}) }; + let r = app.run(then, p).unwrap(); + assert!(!app.session.tool_editing(), "{then}"); + let merged = app.session.doc().unwrap().history.undo.last().map(|e| e.label.clone()); + assert_eq!(merged.as_deref(), Some("Shape Builder"), "{then}"); + match then { + "file.saveAs" => assert!(!app.session.doc().unwrap().is_dirty(), "the file holds the merge"), + "file.exportAs" => assert!(app.session.doc().unwrap().is_dirty(), "an export leaves the document modified"), + _ => assert_eq!(r["pending"], "saveChanges", "closing asks to save the merge"), + } + } +} + /// `ui.dialog.set` of a field the open dialog does not have is an error that lists its fields, /// and the dialog stays as it was. Fields a dialog may not hold yet are still set: `discard` /// with a discard button, `preview` with a live preview, `__` fields and the spec's `inputs`. diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs index 05df48941..88b14339b 100644 --- a/crates/ui-egui/src/io/export.rs +++ b/crates/ui-egui/src/io/export.rs @@ -33,6 +33,9 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option st.doc.clone(), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0262a21ec..cd704b491 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -131,6 +131,9 @@ pub struct VectorcraftApp { control_rx: Option>, /// Window captures asked for and not answered yet. pending_screenshots: Vec, + /// `ui.pointer` replies waiting for their events and the edit the tool computes on another + /// thread ([`control::Outcome::AfterToolEdit`]), with the numbers of those events. + pointer_replies: Vec<(u64, u64, Sender)>, /// Admitted captures whose settled frame has not come yet (issued one at a time). queued_screenshots: Vec, screenshot_token: u64, diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 9b4dd262f..47ad6faad 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -109,6 +109,11 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { if waits_for_ime(app, id) { return; } + // A command chosen while the tool computes an edit on another thread (a Shape Builder merge) + // comes after it: the edit is applied first, so the command's dialog, or the app's own dialog + // (Recolor Artwork), opens on the art as the gesture left it. + let r = app.session.finish_tool_edit(); + crate::canvas::apply_requests(app, r); if SUBJECT_COMMANDS.contains(&id) && p.get("ids").is_none() && p.get("id").is_none() && !has_subject(app) { return; } diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 57137bee5..ffbb52ae9 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -586,16 +586,21 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::attributes::open_url(app, p), "ui.javascriptFilesDialog" => crate::panels::svg_interactivity::files_dialog(app, p), "ui.spotColors" => crate::dialogs::spot_colors::open(app), - "ui.menuDialog" => match s("command").as_deref() { - Some(c) if crate::dialogs::envelope::opens(c) => crate::dialogs::envelope::open(app, c), - c => match c.and_then(|c| menu_dialog(app, c)) { - Some((kind, fields)) => { - app.ui.dialog = Some(crate::state::Dialog::new(kind, fields)); - Ok(Value::Null) - } - None => Err("`command` must be a command whose menu item opens a dialog (see ui.menuDialog)".into()), - }, - }, + "ui.menuDialog" => { + // As from the menu ([`invoke`]), the edit the tool computes on another thread first. + let r = app.session.finish_tool_edit(); + crate::canvas::apply_requests(app, r); + match s("command").as_deref() { + Some(c) if crate::dialogs::envelope::opens(c) => crate::dialogs::envelope::open(app, c), + c => match c.and_then(|c| menu_dialog(app, c)) { + Some((kind, fields)) => { + app.ui.dialog = Some(crate::state::Dialog::new(kind, fields)); + Ok(Value::Null) + } + None => Err("`command` must be a command whose menu item opens a dialog (see ui.menuDialog)".into()), + }, + } + } "ui.widthPointEdit" => crate::dialogs::width_point::open(app, p), "ui.corners" => crate::dialogs::corners::open(app, p), "ui.hyphenation" => crate::dialogs::hyphenation::open(app), diff --git a/crates/ui-egui/src/placing.rs b/crates/ui-egui/src/placing.rs index 0c94c2735..ba54f2ebe 100644 --- a/crates/ui-egui/src/placing.rs +++ b/crates/ui-egui/src/placing.rs @@ -66,6 +66,10 @@ pub fn available(app: &VectorcraftApp) -> bool { /// checked here first, so a bad one is an error at once. When no thread could start, the file is /// placed at once → `file.place`'s reply. pub fn start(app: &mut VectorcraftApp, p: &Value, dropped: bool) -> Result { + // The edit the tool computes on another thread comes first: the object Replace swaps is the + // one selected after it. + let r = app.session.finish_tool_edit(); + crate::canvas::apply_requests(app, r); let job = app.session.prepare_place(p).map_err(|e| e.to_string())?; let name = job.name(); let started = spawn(app, name.clone(), name, dropped, move || job.read().map(|r| Read::Place(Box::new(r))).map_err(|e| e.to_string()))?; diff --git a/crates/ui-egui/src/placing/tests.rs b/crates/ui-egui/src/placing/tests.rs index 0627b37bc..10c4abf51 100644 --- a/crates/ui-egui/src/placing/tests.rs +++ b/crates/ui-egui/src/placing/tests.rs @@ -303,6 +303,39 @@ fn a_replace_whose_object_was_deleted_meanwhile_places_nothing() { assert!(state(&app) == cleared, "nothing placed"); } +/// A Replace started while a Shape Builder merge is computed on another thread swaps the object +/// as the merge leaves it: the merge is applied first. +#[test] +fn a_replace_during_a_merge_on_another_thread_swaps_the_merged_object() { + use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + let path = saved("replace-merge", "photo.tif", &picture(Tiff, 60, 40)); + let mut app = app(true); + for k in 0..40 { + app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap(); + } + app.run("select.all", json!({})).unwrap(); + app.run("object.group", json!({})).unwrap(); + app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap(); + let view = app.view_info(); + let at = |kind, x| PointerEvent { kind, pos: vectorcraft_geom::Point::new(x, 10.0), mods: Mods::default(), pressure: 1.0 }; + crate::canvas::dispatch(&mut app, &at(PointerKind::Move, 5.0), view); + app.session.tool_poll(view, true).unwrap(); + // A drag across the row merges every square into one object, the one Replace swaps. + for (kind, x) in [(PointerKind::Down, 5.0), (PointerKind::Drag, 200.0), (PointerKind::Drag, 405.0), (PointerKind::Up, 405.0)] { + crate::canvas::dispatch(&mut app, &at(kind, x), view); + } + assert!(app.session.tool_editing()); + start(&mut app, &json!({"path": path, "replace": true}), false).unwrap(); + assert!(!app.session.tool_editing(), "the merge is applied first"); + placed(&mut app); + assert!(app.ui.status.is_empty(), "{}", app.ui.status); + let mut placed_names = vec![]; + app.session.doc().unwrap().doc.walk(|n| placed_names.extend(n.name.clone())); + assert!(placed_names.iter().any(|n| n == "photo.tif"), "the merged art is replaced: {placed_names:?}"); + let labels: Vec = app.session.doc().unwrap().history.undo.iter().rev().take(2).map(|e| e.label.clone()).collect(); + assert_eq!(labels.last().map(String::as_str), Some("Shape Builder"), "{labels:?}"); +} + /// A file read while a dialog is open waits for the dialog to close, as a document that opens /// does (#1694): the dialog's OK acts on the active document. #[test] diff --git a/crates/ui-egui/src/unsaved.rs b/crates/ui-egui/src/unsaved.rs index 4518dcb7f..46b665efc 100644 --- a/crates/ui-egui/src/unsaved.rs +++ b/crates/ui-egui/src/unsaved.rs @@ -23,11 +23,19 @@ fn refuse_while_picking(app: &VectorcraftApp) -> Result<(), String> { if app.file_sheet_open() { Err(crate::picks::busy().into()) } else { Ok(()) } } +/// Finishes the work that decides whether there is anything left to save: the saves running in +/// the background, and an edit the active tool is computing on another thread (a Shape Builder +/// merge), which is applied. +pub fn finish_pending(app: &mut VectorcraftApp) { + crate::background::wait_all(app); + let r = app.session.finish_tool_edit(); + crate::canvas::apply_requests(app, r); +} + /// File → Close (or a tab's ×) for document `i`. pub fn close(app: &mut VectorcraftApp, i: usize) -> Result { refuse_while_picking(app)?; - // A save still running decides whether there is anything left to save. - crate::background::wait_all(app); + finish_pending(app); let dirty = app.session.documents().get(i).ok_or("no such document")?.is_dirty(); if dirty { ask(app, i, "close") } else { close_now(app, i) } } @@ -36,7 +44,7 @@ pub fn close(app: &mut VectorcraftApp, i: usize) -> Result { /// or finish once none is left. pub fn close_all(app: &mut VectorcraftApp, then: &str) -> Result { refuse_while_picking(app)?; - crate::background::wait_all(app); + finish_pending(app); if let Some(i) = app.session.documents().iter().position(|d| d.is_dirty()) { return ask(app, i, then); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7bfbcfe16..68590d226 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -96,7 +96,7 @@ the two can still win that race. | `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item | | `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected. While a menu opened over locked objects is open, it has an `Unlock` submenu with one item for each locked object under the pointer, topmost first and at most 20, labeled with the object's name, which runs `layer.setProps {ids: [id], locked: false}`. A locked group gets one item, and the locked objects inside it get none. Hidden objects, objects on hidden, locked or template layers, and in isolation mode objects outside the isolated group get no item | | `ui.tool.select` / `ui.tool.list` | `{tool}` | | -| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse: `mods.cmd` held at a press with any tool but a selection tool drags with the selection tool chosen last (Direct Selection with the Pen until one is), and the release gives the tool back as it was; the reply lists `selection`, `key` (the key object, null without one) and `tool`. With the Shape Builder or the Live Paint Bucket and a selection of more than 128 segments, the app finds the regions off the UI thread, so a `move` replies before the region under the pointer is highlighted, and the highlight shows in the first frame after the regions are found | +| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse: `mods.cmd` held at a press with any tool but a selection tool drags with the selection tool chosen last (Direct Selection with the Pen until one is), and the release gives the tool back as it was; the reply lists `selection`, `key` (the key object, null without one) and `tool`. With the Shape Builder or the Live Paint Bucket and a selection of more than 128 segments, the app finds the regions off the UI thread, so a `move` replies before the region under the pointer is highlighted, and the highlight shows in the first frame after the regions are found. With the Shape Builder and such a selection, the app also merges off the UI thread: the reply to the events that end a click or drag comes once the merge is applied. A click or drag made before the regions are found does not wait for them; the app records it and makes it once they are found. Until then the canvas keeps drawing, with the touched regions highlighted and the busy pointer. A click or drag sent meanwhile, with `mods.cmd` too, and the Shape Builder's arrow keys are made after the merge in the order sent, and the reply to such a request comes once its events are made and any merge they start is applied; a request of `move` events alone is answered at once. A command that can edit, select, save or redraw the documents (an edit, an undo, a selection, a save or an export, a tool option, another tool, another document, closing, a dialog's preview, a menu item, a command of the app's own such as `ui.recolorDialog`, the proof and separations views, a preference, added fonts) waits for the merge and the input sent before it, and runs after them; queries that only read, such as `document.inspect`, run at once on the document before the merge | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | synthetic keyboard input | | `ui.move` | `{x, y}` | screen-point pointer move | | `ui.click` / `ui.drag` | `{x, y, button?, count?, shift?, alt?, ctrl?, cmd?}` / `{x, y, toX, toY, steps?, count?, button?, shift?, alt?, ctrl?, cmd?}` | egui pointer input; see synthetic input budgets above | diff --git a/docs/mcp.md b/docs/mcp.md index 2bef0d6fb..9f884f22b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -412,7 +412,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `inspect_document` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | Artboards (with `color`, `#rrggbb`, for one with a background color), layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. Paint comes as a label (`"#ff0000"`, `"None"`, `"G1 40% (#ff9999)"` for a swatch at a tint); `paint.proxies` gives it as an object. | | `inspect_ui` | `{}` | UI state. Remote mode only. | | `select_tool` | `{tool}` | `selection`, `directSelection`, `pen`, `rectangle`, `ellipse`, `polygon`, `star`, `lineSegment`, … | -| `pointer_gesture` | `{events:[{kind,x,y,mods?}], tool?, mods?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. | +| `pointer_gesture` | `{events:[{kind,x,y,mods?}], tool?, mods?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. The release of a Shape Builder click or drag over such a selection replies once the merge is applied, headless and in the desktop app, which computes the merge off the UI thread. In the desktop app, a gesture sent before then is made after the merge, and its reply comes once it is made; a command that can edit, select, save or redraw the documents (a preference, the proof and separations views), sent before then, waits for the merge and runs after it. | | `draw_path` | `{points \| d, closed?, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `points` is `[[x,y],…]` or `[{x,y,in?,out?,smooth?},…]`. `d` is SVG path data. The options are below. | | `draw_shape` | `{shape, …geometry, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `rectangle`/`ellipse`: `x,y,width,height` (plus `radius` for corners). `polygon`: `cx,cy,radius,sides`. `star`: `cx,cy,radius1,radius2,points`. `line`: `x1,y1,x2,y2`. | | `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. Returns `{applied, objects}`: `objects` is the number of objects painted, as the `objects` of the `paint.setFill`, `paint.setStroke` and `stroke.set` replies (the largest of them): a selected group counts the objects inside it. When it is 0, a `note` says that only the paint for new art changed. | From 83748190f69f3eb30ec860ab189d53c054e1a339 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 17:15:07 -0700 Subject: [PATCH 02/71] Shape Builder: import waits_for_tool_edit and Arc after merging main main moved session/execute.rs and cmd/buildcmds/shape_builder.rs off glob imports, so the branch's new waits_for_tool_edit guard and Arc::ptr_eq call needed those names added to the explicit import lists. --- crates/engine/src/cmd/buildcmds/shape_builder.rs | 6 +++--- crates/engine/src/session.rs | 2 +- crates/engine/src/session/execute.rs | 1 + 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds/shape_builder.rs b/crates/engine/src/cmd/buildcmds/shape_builder.rs index b230dd427..7d9b7e7ab 100644 --- a/crates/engine/src/cmd/buildcmds/shape_builder.rs +++ b/crates/engine/src/cmd/buildcmds/shape_builder.rs @@ -1,7 +1,7 @@ use super::{ - Beside, BuilderMap, EngineError, FillRule, Node, NodeId, PathData, Point, Rect, Result, Session, SubPath, Touched, Value, b, bad, check_numbers, - f64_or, fill_param, finite, gap_param, ids_json, json, marquee_param, po, points_param, remnant, replace_roots, replace_with_pieces, root_ids, - shape_node, switch_or, + Arc, Beside, BuilderMap, EngineError, FillRule, Node, NodeId, PathData, Point, Rect, Result, Session, SubPath, Touched, Value, b, bad, + check_numbers, f64_or, fill_param, finite, gap_param, ids_json, json, marquee_param, po, points_param, remnant, replace_roots, + replace_with_pieces, root_ids, shape_node, switch_or, }; pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 1eaf15b88..340c081a2 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -1,4 +1,4 @@ -use super::command_sets::{SELECTS_GIVEN_IDS, given_ids, strip_replay_only}; +use super::command_sets::{SELECTS_GIVEN_IDS, given_ids, strip_replay_only, waits_for_tool_edit}; use super::doc_state::DocState; use super::error::{EngineError, Result}; use super::history::NamedStep; diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 0a5a31b90..373e59f5c 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -1,5 +1,6 @@ use super::{ CommandSpec, EngineError, NodeId, Result, SELECTS_GIVEN_IDS, Selection, Session, Value, cmd, find_command, given_ids, guard, strip_replay_only, + waits_for_tool_edit, }; impl Session { From 963346984eecc9362595ccee4f8ad9fef4ba786d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 17:42:35 -0700 Subject: [PATCH 03/71] Shape Builder: import find_command and drop the unused SubPath import after merging main --- crates/engine/src/cmd/buildcmds/shape_builder.rs | 6 +++--- crates/engine/src/command_sets.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds/shape_builder.rs b/crates/engine/src/cmd/buildcmds/shape_builder.rs index 7d9b7e7ab..c64c19915 100644 --- a/crates/engine/src/cmd/buildcmds/shape_builder.rs +++ b/crates/engine/src/cmd/buildcmds/shape_builder.rs @@ -1,7 +1,7 @@ use super::{ - Arc, Beside, BuilderMap, EngineError, FillRule, Node, NodeId, PathData, Point, Rect, Result, Session, SubPath, Touched, Value, b, bad, - check_numbers, f64_or, fill_param, finite, gap_param, ids_json, json, marquee_param, po, points_param, remnant, replace_roots, - replace_with_pieces, root_ids, shape_node, switch_or, + Arc, Beside, BuilderMap, EngineError, FillRule, Node, NodeId, PathData, Point, Rect, Result, Session, Touched, Value, b, bad, check_numbers, + f64_or, fill_param, finite, gap_param, ids_json, json, marquee_param, po, points_param, remnant, replace_roots, replace_with_pieces, root_ids, + shape_node, switch_or, }; pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index 96d332583..6e7db0b10 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -1,4 +1,4 @@ -use super::{NodeId, Value, cmd}; +use super::{NodeId, Value, cmd, find_command}; /// The commands that act on the active artboard (#693), which the app passes as their `artboard`: /// Paste in Place, in Front and in Back paste onto it, Align to Artboard aligns to it, Distribute From a368683eb4ab26283ef605000c0237e04cb05b0e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 17:51:28 -0700 Subject: [PATCH 04/71] Shape Builder: Find Objects with select waits for a pending merge document.find is a query, so it ran at once while a Shape Builder merge was still being computed. With select it changes the selection, and the pending merge was then discarded. It now applies the merge first, as the other queries that select do. The command sweep also runs document.find with a filter and select, which one key at a time does not reach. --- crates/engine/src/command_sets.rs | 7 ++++--- crates/engine/tests/command_sweep.rs | 4 ++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index 6e7db0b10..8ebaf05f9 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -64,11 +64,12 @@ pub(crate) fn strip_replay_only(id: &str, p: &mut Value) { } /// Queries that apply the edit the active tool is computing before they run, as edits do -/// ([`Session::finish_tool_edit`]): a batch runs edits, Select All Instances and Lock Guides -/// change the selection, and the proof views, the preferences and added fonts give the documents -/// new revisions (they draw again, and type lays out again). +/// ([`Session::finish_tool_edit`]): a batch runs edits, Find Objects with `select`, Select All +/// Instances and Lock Guides change the selection, and the proof views, the preferences and added +/// fonts give the documents new revisions (they draw again, and type lays out again). const WAIT_FOR_TOOL_EDIT: &[&str] = &[ "command.batch", + "document.find", "symbol.selectInstances", "view.guides.lock", "view.proofSetup", diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index c584e1ed1..768bbecf2 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -250,6 +250,10 @@ fn commands_that_do_not_wait_for_a_tool_edit_change_no_document() { cases.push(json!({ k.as_str(): v })); } } + // Find Objects selects only with a filter, which one key at a time does not give it. + if c.id == "document.find" { + cases.extend([json!({"kind": "Path", "select": true}), json!({"name": "no such name", "select": true})]); + } for p in &cases { let mut s = fx.session(); let before = tool_edit_state(&s); From 5da4798da49d13c9026e372cb7f7febbd99d7a8d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 20:36:35 -0700 Subject: [PATCH 05/71] Shape Builder: import canvas in the app's command and control modules after merging main --- crates/ui-egui/src/app/commands.rs | 2 +- crates/ui-egui/src/app/control.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 45c2fdf23..6ca783325 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -1,5 +1,5 @@ use super::fonts::MAX_RECENT_FONTS; -use super::{Value, VectorcraftApp, dialogs, fileio, io, menus, panels, picks, toolbar}; +use super::{Value, VectorcraftApp, canvas, dialogs, fileio, io, menus, panels, picks, toolbar}; impl VectorcraftApp { /// Run a UI or engine command by id. The single entry point for every frontend path. diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index eca869c6c..6d095d484 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -1,6 +1,6 @@ use super::{ MAX_PENDING_SCREENSHOTS, MAX_SCREENSHOT_BYTES, MAX_SCREENSHOT_TOTAL_BYTES, PendingScreenshot, QueuedScreenshot, SCREENSHOT_TIMEOUT_MS, - VectorcraftApp, file_access, json, now_ms, + VectorcraftApp, canvas, file_access, json, now_ms, }; use crate::control; From 6cb7b341d6d57f5f4af1659e264b94e41bbbb9c4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 05:18:59 -0700 Subject: [PATCH 06/71] Shape Builder: Escape cancels a gesture made while the map is built A drag started while the map of a large selection was being built was recorded for replay. Escape ended the drag but kept the recorded gesture, so the release was ignored, the tool kept waiting, and the next drag's events were added to the canceled one. Escape now drops it. --- crates/tools/src/builder.rs | 2 ++ crates/tools/src/builder/tests.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index 4203a6b7c..dd7b1de2a 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -718,6 +718,8 @@ impl Tool for ShapeBuilderTool { self.dragging = false; self.points.clear(); self.touched = Touched::default(); + // A gesture recorded while the map is being built is canceled with it. + self.deferred = None; (self.marquee, self.hover, self.far) = (None, None, false); return vec![]; } diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index 5f69acce1..499d40668 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -430,6 +430,36 @@ fn shape_builder_gesture_waits_for_the_map_without_holding_the_caller() { assert!(!t.editing()); } +/// Escape during a drag made while the map of a large selection is being built cancels the +/// recorded gesture too: the release and the polls after it merge nothing, the tool stops +/// waiting once the map is there, and the next drag merges on its own. +#[test] +fn shape_builder_escape_cancels_a_gesture_made_while_the_map_is_built() { + let (d, s) = square_row(200); + let p = paint(); + let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) }; + let mut t = ShapeBuilderTool::default(); + t.pointer(&cx1, &ev(PointerKind::Move, 15.0, 10.0)); + assert!(t.pointer(&cx1, &ev(PointerKind::Down, 15.0, 10.0)).is_empty()); + assert!(t.pointer(&cx1, &ev(PointerKind::Drag, 35.0, 10.0)).is_empty()); + assert_eq!(t.cache.builds, 0, "the gesture did not wait for the map"); + assert!(t.claims_key(&cx1, ToolKey::Escape)); + assert!(t.key(&cx1, ToolKey::Escape, Mods::default()).is_empty()); + assert!(t.pointer(&cx1, &ev(PointerKind::Up, 35.0, 10.0)).is_empty()); + assert!(poll_actions(&mut t, &cx1).is_empty(), "the canceled gesture merges nothing"); + assert!(!t.waiting() && !t.editing()); + // The next drag merges what it touches, with no event of the canceled one. + let mut fresh = ShapeBuilderTool::default(); + fresh.pointer(&cx1, &ev(PointerKind::Move, 55.0, 10.0)); + poll_until_ready(&mut fresh, &cx1); + let next = [ev(PointerKind::Down, 55.0, 10.0), ev(PointerKind::Drag, 75.0, 10.0), ev(PointerKind::Up, 75.0, 10.0)]; + for e in &next { + fresh.pointer(&cx1, e); + t.pointer(&cx1, e); + } + assert_eq!(poll_actions(&mut t, &cx1), poll_actions(&mut fresh, &cx1)); +} + #[test] fn shape_builder_hover_builds_a_small_map_at_once() { let (d, s) = square_row(3); From 061b519e20804d48ceefb81a69b8800085b4b6b7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 10:06:08 -0700 Subject: [PATCH 07/71] Shape Builder: a deferred pointer reply describes the gesture's document A ui.pointer reply that waits for the merge now records the document and tool of the gesture and reads the selection from that document when it is sent, so a command that switches documents first (such as file.new) no longer makes the reply report the other document. --- crates/ui-egui/src/app/control.rs | 11 +++++++---- crates/ui-egui/src/control.rs | 30 +++++++++++++++++++++++++---- crates/ui-egui/src/control/tests.rs | 25 ++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 4 ++-- crates/ui-egui/src/state.rs | 3 ++- 5 files changed, 62 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 6d095d484..f2a35b16c 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -88,7 +88,9 @@ impl VectorcraftApp { } } } - control::Outcome::AfterToolEdit { from, to } => self.pointer_replies.push((from, to, reply)), + control::Outcome::AfterToolEdit { from, to, document, tool } => { + self.pointer_replies.push(control::PendingPointerReply { from, to, document, tool, reply }); + } } } if handled == control::MAX_CONTROL_REQUESTS_PER_FRAME { @@ -120,11 +122,12 @@ impl VectorcraftApp { /// Sends the waiting `ui.pointer` replies whose events have been made. fn send_pointer_replies(&mut self) { - for (from, to, reply) in std::mem::take(&mut self.pointer_replies) { + for pending in std::mem::take(&mut self.pointer_replies) { + let control::PendingPointerReply { from, to, document, tool, reply } = pending; if self.session.input_applied(from, to) { - let _ = reply.send(control::pointer_reply(self)); + let _ = reply.send(control::pointer_reply_for(self, document, &tool)); } else { - self.pointer_replies.push((from, to, reply)); + self.pointer_replies.push(control::PendingPointerReply { from, to, document, tool, reply }); } } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d5a0e2ef6..2e632616c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -95,6 +95,14 @@ fn reserve_synthetic(app: &mut VectorcraftApp, additional: usize) -> Result<(), pub type ControlResponse = Value; +pub(crate) struct PendingPointerReply { + pub from: u64, + pub to: u64, + pub document: Option, + pub tool: String, + pub reply: Sender, +} + pub struct ControlRequest { pub method: String, pub params: Value, @@ -151,14 +159,28 @@ pub enum Outcome { AfterToolEdit { from: u64, to: u64, + document: Option, + tool: String, }, } /// The reply to `ui.pointer`: the selection, the key object and the active tool. pub(crate) fn pointer_reply(app: &mut VectorcraftApp) -> Value { - match app.run("document.inspect", json!({})) { - Ok(d) => json!({"ok": true, "result": {"selection": d["selection"], "key": d["key"], "tool": app.session.tool_id()}}), - Err(e) => json!({"ok": false, "error": e.to_string()}), + pointer_reply_for(app, app.session.active().map(|d| d.uid), app.session.tool_id()) +} + +pub(crate) fn pointer_reply_for(app: &VectorcraftApp, document: Option, tool: &str) -> Value { + let state = document.and_then(|uid| app.session.documents().iter().find(|d| d.uid == uid)); + match state { + Some(state) => json!({ + "ok": true, + "result": { + "selection": state.selection.objects.iter().map(|id| id.0).collect::>(), + "key": state.selection.key.map(|id| id.0), + "tool": tool, + } + }), + None => json!({"ok": false, "error": "the gesture's document is no longer open"}), } } @@ -558,7 +580,7 @@ fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRe // a hover is answered at once. let to = app.session.input_count(); if !app.session.input_applied(from, to) { - return Outcome::AfterToolEdit { from, to }; + return Outcome::AfterToolEdit { from, to, document: app.session.active().map(|d| d.uid), tool: app.session.tool_id().to_string() }; } Outcome::Done(pointer_reply(app)) } diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index d0decf73a..fc2ef0100 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -12,6 +12,31 @@ fn input_request(app: &mut VectorcraftApp, method: &str, params: Value) -> Value r } +#[test] +fn deferred_pointer_reply_uses_the_gesture_document_after_file_new() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let (reply, received) = std::sync::mpsc::channel(); + let at = app.session.input_count(); + app.pointer_replies.push(super::PendingPointerReply { + from: at, + to: at, + document: app.session.active().map(|d| d.uid), + tool: app.session.tool_id().to_string(), + reply, + }); + + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.answer_pointer_replies(&egui::Context::default()); + + let result = received.try_recv().unwrap(); + assert_eq!(result["ok"], true, "{result}"); + assert_eq!(result["result"]["selection"], json!([id])); + assert_eq!(result["result"]["key"], Value::Null); +} + #[test] fn ui_set_turns_the_image_trace_preview_off_and_on() { let mut app = VectorcraftApp::new(Session::new(), Services::default()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cf01a3e3d..f76ec238c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -81,7 +81,7 @@ use tests::{ mod tests; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc::{Receiver, Sender}; +use std::sync::mpsc::Receiver; use std::sync::{Arc, Mutex}; use serde_json::{Value, json}; @@ -136,7 +136,7 @@ pub struct VectorcraftApp { pending_screenshots: Vec, /// `ui.pointer` replies waiting for their events and the edit the tool computes on another /// thread ([`control::Outcome::AfterToolEdit`]), with the numbers of those events. - pointer_replies: Vec<(u64, u64, Sender)>, + pointer_replies: Vec, /// Admitted captures whose settled frame has not come yet (issued one at a time). queued_screenshots: Vec, screenshot_token: u64, diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e7460b7e8..1d53724c9 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -66,8 +66,9 @@ pub const ROTATE_VIEW_ANGLES: [(&str, f64); 12] = [ ("-90°", -90.0), ]; -use super::{ControlResponse, Sender}; +use super::ControlResponse; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::Sender; /// Zoom stop stepping reads this (set each UI frame and before commands) so canvas wheel zoom /// matches the status bar when Display Print Size at 100% Zoom is on. From c45707a13e51d38bfdde825d202860fcb208f8a1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 10:49:02 -0700 Subject: [PATCH 08/71] Shape Builder: a deferred pointer reply names the tool active after the gesture --- crates/ui-egui/src/app/control.rs | 10 +++++----- crates/ui-egui/src/control.rs | 10 ++++------ crates/ui-egui/src/control/tests.rs | 10 +++------- 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index f2a35b16c..62a8366e7 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -88,8 +88,8 @@ impl VectorcraftApp { } } } - control::Outcome::AfterToolEdit { from, to, document, tool } => { - self.pointer_replies.push(control::PendingPointerReply { from, to, document, tool, reply }); + control::Outcome::AfterToolEdit { from, to, document } => { + self.pointer_replies.push(control::PendingPointerReply { from, to, document, reply }); } } } @@ -123,11 +123,11 @@ impl VectorcraftApp { /// Sends the waiting `ui.pointer` replies whose events have been made. fn send_pointer_replies(&mut self) { for pending in std::mem::take(&mut self.pointer_replies) { - let control::PendingPointerReply { from, to, document, tool, reply } = pending; + let control::PendingPointerReply { from, to, document, reply } = pending; if self.session.input_applied(from, to) { - let _ = reply.send(control::pointer_reply_for(self, document, &tool)); + let _ = reply.send(control::pointer_reply_for(self, document)); } else { - self.pointer_replies.push(control::PendingPointerReply { from, to, document, tool, reply }); + self.pointer_replies.push(control::PendingPointerReply { from, to, document, reply }); } } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2e632616c..e61c0d048 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -99,7 +99,6 @@ pub(crate) struct PendingPointerReply { pub from: u64, pub to: u64, pub document: Option, - pub tool: String, pub reply: Sender, } @@ -160,16 +159,15 @@ pub enum Outcome { from: u64, to: u64, document: Option, - tool: String, }, } /// The reply to `ui.pointer`: the selection, the key object and the active tool. pub(crate) fn pointer_reply(app: &mut VectorcraftApp) -> Value { - pointer_reply_for(app, app.session.active().map(|d| d.uid), app.session.tool_id()) + pointer_reply_for(app, app.session.active().map(|d| d.uid)) } -pub(crate) fn pointer_reply_for(app: &VectorcraftApp, document: Option, tool: &str) -> Value { +pub(crate) fn pointer_reply_for(app: &VectorcraftApp, document: Option) -> Value { let state = document.and_then(|uid| app.session.documents().iter().find(|d| d.uid == uid)); match state { Some(state) => json!({ @@ -177,7 +175,7 @@ pub(crate) fn pointer_reply_for(app: &VectorcraftApp, document: Option, too "result": { "selection": state.selection.objects.iter().map(|id| id.0).collect::>(), "key": state.selection.key.map(|id| id.0), - "tool": tool, + "tool": app.session.tool_id(), } }), None => json!({"ok": false, "error": "the gesture's document is no longer open"}), @@ -580,7 +578,7 @@ fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRe // a hover is answered at once. let to = app.session.input_count(); if !app.session.input_applied(from, to) { - return Outcome::AfterToolEdit { from, to, document: app.session.active().map(|d| d.uid), tool: app.session.tool_id().to_string() }; + return Outcome::AfterToolEdit { from, to, document: app.session.active().map(|d| d.uid) }; } Outcome::Done(pointer_reply(app)) } diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index fc2ef0100..9427675a4 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -20,21 +20,17 @@ fn deferred_pointer_reply_uses_the_gesture_document_after_file_new() { app.run("select.set", json!({"ids": [id]})).unwrap(); let (reply, received) = std::sync::mpsc::channel(); let at = app.session.input_count(); - app.pointer_replies.push(super::PendingPointerReply { - from: at, - to: at, - document: app.session.active().map(|d| d.uid), - tool: app.session.tool_id().to_string(), - reply, - }); + app.pointer_replies.push(super::PendingPointerReply { from: at, to: at, document: app.session.active().map(|d| d.uid), reply }); app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("tool.select", json!({"tool": "rectangle"})).unwrap(); app.answer_pointer_replies(&egui::Context::default()); let result = received.try_recv().unwrap(); assert_eq!(result["ok"], true, "{result}"); assert_eq!(result["result"]["selection"], json!([id])); assert_eq!(result["result"]["key"], Value::Null); + assert_eq!(result["result"]["tool"], "rectangle"); } #[test] From 7f843e1e4a799ddd1e5d575fdecc58a7f22eef4a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 07:59:14 -0800 Subject: [PATCH 09/71] .ai import: objects with a variable-width stroke come in as their drawn outline The editing data keeps each styled object's drawn look, the outline as the file draws it, next to the editable path. An object whose art style was one stroke with a width profile came in as a live stroke instead, and VectorCraft's width profile does not draw these strokes at the widths the file draws them, so outlines no longer met the fills built against them. Such an object now comes in as its drawn look, as other styles that VectorCraft cannot draw the same way already do. Type keeps the stroke and its profile live, since its drawn look cannot be retyped. The import warning for drawn looks now names variable-width strokes. --- crates/engine/tests/import_fuzz.rs | 3 ++ crates/eps/src/import/ai/read/document.rs | 2 +- crates/eps/src/import/ai/style.rs | 10 ++-- crates/eps/src/import/ai/style/tests.rs | 64 ++++++++++++++++++----- crates/eps/src/import/ai/tests.rs | 47 +++++++++++++++++ crates/eps/src/import/tests_native.rs | 30 +++++++++++ crates/ui-egui/src/i18n/bg.tsv | 2 +- crates/ui-egui/src/i18n/bs.tsv | 2 +- crates/ui-egui/src/i18n/ca.tsv | 2 +- crates/ui-egui/src/i18n/cs.tsv | 2 +- crates/ui-egui/src/i18n/da.tsv | 2 +- crates/ui-egui/src/i18n/de.tsv | 2 +- crates/ui-egui/src/i18n/el.tsv | 2 +- crates/ui-egui/src/i18n/es.tsv | 2 +- crates/ui-egui/src/i18n/fi.tsv | 2 +- crates/ui-egui/src/i18n/fr.tsv | 2 +- crates/ui-egui/src/i18n/hu.tsv | 2 +- crates/ui-egui/src/i18n/id.tsv | 2 +- crates/ui-egui/src/i18n/it.tsv | 2 +- crates/ui-egui/src/i18n/ja.tsv | 2 +- crates/ui-egui/src/i18n/ka.tsv | 2 +- crates/ui-egui/src/i18n/ko.tsv | 2 +- crates/ui-egui/src/i18n/nl.tsv | 2 +- crates/ui-egui/src/i18n/pl.tsv | 2 +- crates/ui-egui/src/i18n/pt-br.tsv | 2 +- crates/ui-egui/src/i18n/ru.tsv | 2 +- crates/ui-egui/src/i18n/sv.tsv | 2 +- crates/ui-egui/src/i18n/tr.tsv | 2 +- crates/ui-egui/src/i18n/uk.tsv | 2 +- crates/ui-egui/src/i18n/vi.tsv | 2 +- crates/ui-egui/src/i18n/zh-hans.tsv | 2 +- crates/ui-egui/src/i18n/zh-hant.tsv | 2 +- docs/file-format-parity.md | 2 +- 33 files changed, 166 insertions(+), 44 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 19c6ef93a..ba56718ab 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3401,6 +3401,9 @@ fn arb_ai_token() -> impl Strategy { "/Execution ;", "[5 5] 1e308 d", "-0.5 /Real ,", + "0.5 /Real ,", + "1 /Real ,", + "1e308 /Real ,", "1.5 /Real ,", "-2 /Real ,", "; (BSLeftWidthsArray) ,", diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index 0874ec095..4b46f6d2f 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -485,7 +485,7 @@ impl<'a> Reader<'a> { let mut warnings = self.warnings; if self.drawn_looks > 0 { warnings.push(format!( - "objects with several fills or strokes, effects or brushes came in as their drawn look ({}): their appearance isn't editable yet", + "objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({}): their appearance isn't editable yet", self.drawn_looks )); } diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index ede1ae8e9..6dda903c1 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -93,9 +93,9 @@ pub(super) fn warp_style(n: i64) -> Option<&'static str> { pub(super) struct ArtStyle { /// The style reads as [`Self::items`] and [`Self::effects`]. pub plain: bool, - /// It is one fill and one stroke at most, in solid colours or the object's own gradient, with - /// no transparency or effects of their own, and only Outer Glow, Feather and Drop Shadow on the - /// object: what any object is read with. Type takes the other plain styles too (its drawn look + /// It is one fill and one stroke of even width at most, in solid colours or the object's own + /// gradient, with no transparency or effects of their own, and only Outer Glow, Feather and Drop + /// Shadow on the object: what any object is read with. Type takes the other plain styles too (its drawn look /// can't be retyped); other objects keep their drawn look, which is the app's own rendering. pub simple: bool, /// Its fills and strokes, bottom first. @@ -655,7 +655,9 @@ fn style(filters: &[Filter]) -> ArtStyle { st.simple = st.plain && count(true) <= 1 && count(false) <= 1 - && st.items.iter().all(|i| matches!(i, StyleItem::Fill(_) | StyleItem::Stroke { .. } | StyleItem::Gradient { .. })) + // A variable-width stroke curves between its width points, and `WidthProfile` is straight + // between them: the drawn look holds the outline as drawn. + && st.items.iter().all(|i| matches!(i, StyleItem::Fill(_) | StyleItem::Stroke { profile: None, .. } | StyleItem::Gradient { .. })) && st.looks.iter().all(|l| l.visible && l.opacity >= 1.0 && l.blend == BlendMode::Normal && l.effects.is_empty()) && st.effects.iter().all(|e| matches!(e.id.as_str(), "stylize.outerGlow" | "stylize.feather" | "stylize.dropShadow")); st diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index 964b74f10..e68726afe 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -1,19 +1,59 @@ use super::*; -#[test] -fn a_variable_width_stroke_keeps_its_profile() { - let data = b"/KnownStyle :\n(Tapered) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ +/// The style `Tapered`: a 30 pt stroke whose beautiful-strokes dictionary holds `arrays`. +fn width_style(arrays: &str) -> ArtStyle { + let data = format!( + "/KnownStyle :\n(Tapered) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ 2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n/StrokeStyle : 0 R\n0 0 0 0 0 0 1 XA\n0 J 0 j 30 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n\ - /Dictionary : /NotRecorded ,\n2 /Int (BSWidthSource) ,\n/Array :\n0 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n\ - /Array :\n1 /Real ,\n0.1 /Real ,\n; (BSCenteredWidthsArray) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n; /Dict ;\n /Part ,\n\ - /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n"; - let styles = art_styles(data); - let style = &styles["Tapered"]; - assert!(style.plain); - let [StyleItem::Stroke { width, profile, .. }] = style.items.as_slice() else { panic!("{:?}", style.items) }; - assert_eq!(*width, 30.0); - assert_eq!(profile.as_ref().map(|p| p.points.clone()), Some(vec![(0.0, 1.0, 1.0), (1.0, 0.1, 0.1)])); + /Dictionary : /NotRecorded ,\n2 /Int (BSWidthSource) ,\n{arrays}; (kAIBeautifulStrokesParamsDictKey) ,\n; /Dict ;\n /Part ,\n\ + /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n" + ); + art_styles(data.as_bytes()).remove("Tapered").unwrap_or_default() +} + +/// A beautiful-strokes array `key` of `values`. +fn bs_array(key: &str, values: &[&str]) -> String { + let items: String = values.iter().map(|v| format!("{v} /Real ,\n")).collect(); + format!("/Array :\n{items}; ({key}) ,\n") +} + +#[test] +fn a_variable_width_stroke_keeps_its_profile_for_type_only() { + // GUARDS: the profile is straight between its width points, so other objects read with it drew + // other widths than their drawn look and left gaps next to fills built to meet it. + let t = |v: &[&str]| bs_array("BSVariableWidthTValuesArray", v); + let w = |v: &[&str]| bs_array("BSCenteredWidthsArray", v); + for (arrays, points) in [ + (t(&["0", "0.5", "1"]) + &w(&["0", "1", "0"]), vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)]), + (t(&["0", "1"]) + &w(&["1", "0.1"]), vec![(0.0, 1.0, 1.0), (1.0, 0.1, 0.1)]), + ] { + let style = width_style(&arrays); + assert!(style.plain && !style.simple, "{arrays:?}: {style:?}"); + let [StyleItem::Stroke { width, profile, .. }] = style.items.as_slice() else { panic!("{:?}", style.items) }; + assert_eq!((*width, profile.as_ref().map(|p| p.points.clone())), (30.0, Some(points)), "{arrays:?}"); + } +} + +#[test] +fn a_uniform_or_unreadable_width_profile_stays_a_live_stroke() { + let t = |v: &[&str]| bs_array("BSVariableWidthTValuesArray", v); + let w = |v: &[&str]| bs_array("BSCenteredWidthsArray", v); + for arrays in [ + String::new(), + t(&["0", "1"]) + &w(&["1", "1"]), + // No widths, widths of another length, widths that aren't numbers. + t(&["0", "0.5", "1"]), + t(&["0", "0.5", "1"]) + &w(&["0", "1"]), + t(&["0", "1"]) + "/Array :\n(x) /String ,\n(y) /String ,\n; (BSCenteredWidthsArray) ,\n", + // Positions that aren't numbers. + t(&["nan", "nan"]) + &w(&["0", "1"]), + ] { + let style = width_style(&arrays); + assert!(style.plain && style.simple, "{arrays:?}: {style:?}"); + let [StyleItem::Stroke { width, profile, .. }] = style.items.as_slice() else { panic!("{arrays:?}: {:?}", style.items) }; + assert_eq!((*width, profile), (30.0, &None), "{arrays:?}"); + } } #[test] diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index a0a24d865..fff315126 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -1869,6 +1869,53 @@ fn an_object_with_a_feather_style_comes_in_with_a_live_effect() { assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); } +/// The art style `Tapered`: a 4 pt stroke with width points `widths` (position, width) along it. +fn width_profile_style(widths: &[(f64, f64)]) -> String { + let array = |values: Vec| values.iter().map(|v| format!("{v} /Real ,\n")).collect::(); + format!( + "%AI9_BeginArtStyles\n\ + /KnownStyle :\n(Tapered) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n\ + /StrokeStyle : 0 R\n0 0 0 0 0 0 1 XA\n0 J 0 j 4 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n\ + /Dictionary : /NotRecorded ,\n2 /Int (BSWidthSource) ,\n/Array :\n{}; (BSVariableWidthTValuesArray) ,\n/Array :\n{}; (BSCenteredWidthsArray) ,\n\ + ; (kAIBeautifulStrokesParamsDictKey) ,\n; /Dict ;\n /Part ,\n\ + /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n;\n\ + %AI9_EndArtStyles\n", + array(widths.iter().map(|w| w.0).collect()), + array(widths.iter().map(|w| w.1).collect()), + ) +} + +#[test] +fn a_variable_width_stroke_comes_in_as_its_drawn_outline() { + // GUARDS: the object came in as a live stroke whose profile is straight between its width + // points, thinner than the drawn outline, and left gaps next to fills built to meet it. + let hidden_line = "0 5 m\n10 5 L\n".replace('\n', "\n%_"); + let fills = format!("{RED}{}f\n", rect_path(30.0, 0.0, 40.0, 10.0)); + let art_ = format!("{fills}0 Ae\nu\n{RED}{}f\nU\n%_{hidden_line}S\n1 (Tapered) XW\n{fills}", rect_path(0.0, 3.0, 10.0, 7.0)); + let lens = width_profile_style(&[(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)]); + let r = read_ok(&format!("{lens}{}", layer(&art_))); + let a = art(&r.doc); + assert_eq!(a.len(), 3, "{a:#?}"); + let look = &a[1]; + assert!(matches!(look.kind, NodeKind::Group { .. }), "{look:#?}"); + let [outline] = look.children().unwrap().as_slice() else { panic!("{look:#?}") }; + assert!(matches!(outline.kind, NodeKind::Path { .. }) && outline.appearance.fill().is_some(), "{outline:#?}"); + assert!(outline.appearance.stroke().is_none_or(|s| s.profile.is_none()), "{outline:#?}"); + assert!([&a[0], &a[2]].iter().all(|n| matches!(n.kind, NodeKind::Path { .. })), "the objects beside it stay in place"); + assert!(r.warnings.iter().any(|w| w.contains("drawn look (1)")), "{:?}", r.warnings); + + // A uniform profile is a plain stroke: the object comes in as itself. + let uniform = width_profile_style(&[(0.0, 1.0), (1.0, 1.0)]); + let r = read_ok(&format!("{uniform}{}", layer(&art_))); + let a = art(&r.doc); + assert_eq!(a.len(), 3, "{a:#?}"); + assert!(matches!(a[1].kind, NodeKind::Path { .. }), "{:#?}", a[1]); + assert_eq!(a[1].appearance.stroke().map(|s| (s.width, s.profile.is_none())), Some((4.0, true)), "{:#?}", a[1]); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); +} + #[test] fn a_bare_comment_line_in_a_prolog_leaves_its_end_alone() { // GUARDS: a bare `%` line's empty marker matched the empty begin marker of a section that has none. diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 94127e8cf..7fef12c65 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -2956,6 +2956,36 @@ fn type_with_a_stacked_art_style_comes_in_as_type_with_that_appearance() { check(&r.document, &r.warnings); } +#[test] +fn type_with_a_variable_width_stroke_keeps_it_live() { + // GUARDS: other objects with a variable-width stroke come in as their drawn look; type keeps the + // stroke, since its drawn look can't be retyped. + use vectorcraft_doc::AppearanceItem; + let doc = text_document(&[("Bang\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + let style = "%AI9_BeginArtStyles\n/KnownStyle :\n(Tapered) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + 2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n/StrokeStyle : 0 R\n0 0 0 1 0 0 0 XA\n0 J 0 j 2 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n\ + /Dictionary : /NotRecorded ,\n/Array :\n0 /Real ,\n0.5 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n\ + /Array :\n0 /Real ,\n1 /Real ,\n0 /Real ,\n; (BSCenteredWidthsArray) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n; /Dict ;\n /Part ,\n\ + /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n;\n%AI9_EndArtStyles\n"; + let drawing = "0 0 0 1 k\n54 56 m\n100 56 L\n100 80 L\n54 80 L\nf"; + let look = format!("u\n/AI11Text :\n1 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n{drawing}\nU\n9 () XW\n"); + let own: String = text_object(0).lines().map(|l| format!("%_{l}\n")).collect(); + let art = native_with_text(&(style.to_string() + &layer("Art", true, &format!("{look}{own}1 (Tapered) XW\n"))), &doc); + let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); + let mut texts = vec![]; + d.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + texts.push((t.plain_text(), n.appearance.clone())); + } + }); + let [(text, a)] = texts.as_slice() else { panic!("{texts:?} {notes:?}") }; + assert_eq!(text, "Bang"); + let [AppearanceItem::Stroke(stroke)] = a.items.as_slice() else { panic!("{a:?}") }; + assert_eq!(stroke.profile.as_ref().map(|p| p.points.clone()), Some(vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)])); + assert!(!notes.iter().any(|w| w.contains("drawn look")), "{notes:?}"); +} + #[test] fn point_type_without_a_matrix_is_placed_by_its_layout() { // GUARDS: Illustrator 24 leaves an unturned, unscaled point type's matrix out of its frame; such diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f3f6cbfbb..29a9648d2 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4443,7 +4443,7 @@ axis Both И двете @msg it has {_1}, which VectorCraft doesn't read from it yet той съдържа {_1}, който VectorCraft все още не може да разчете @msg its objects are nested too deeply обектите му са вложени твърде дълбоко @msg its section ending with `%{end}` doesn't end разделът му, завършващ на `%{end}`, няма край -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet обектите с множество запълвания или щрихове, ефекти или четки се появиха във вид на изрисувания си изглед ({_1}): тяхната поява все още не подлежи на редактиране +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet обектите с множество запълвания или щрихове, щрихове с променлива ширина, ефекти или четки се появиха във вид на изрисувания си изглед ({_1}): тяхната поява все още не подлежи на редактиране @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it градиентът „{name}“ не е дефиниран във файла: заменен е с обикновен цвят @msg an image VectorCraft can't read was left out изображение, което VectorCraft не може да разчете, беше пропуснато @msg an image whose data is cut short was left out изображение с непълни данни беше пропуснато diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index d7efa9c0e..cd0dcbfce 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4845,7 +4845,7 @@ imageTrace Outline Obris @msg it has {_1}, which VectorCraft doesn't read from it yet sadrži {_1}, što VectorCraft još ne čita iz nje @msg its objects are nested too deeply njeni objekti su previše duboko ugniježđeni @msg its section ending with `%{end}` doesn't end njen odjeljak koji završava s `%{end}` ne završava -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekti s više ispuna ili poteza, efekata ili četkica stigli su kao njihov iscrtani izgled ({_1}): njihov izgled se još ne može uređivati +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekti s više ispuna ili poteza, poteza promjenljive širine, efekata ili četkica stigli su kao njihov iscrtani izgled ({_1}): njihov izgled se još ne može uređivati @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it gradijent „{name}” nije definisan u datoteci: umjesto njega koristi se obična boja @msg an image VectorCraft can't read was left out izostavljena je slika koju VectorCraft ne može pročitati @msg an image whose data is cut short was left out izostavljena je slika čiji su podaci skraćeni diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index cd6172d6a..8cb87366f 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5266,7 +5266,7 @@ digits Malayalam Malaiàlam @msg object {id} is not a selected top-level object l’objecte {id} no és un objecte de nivell superior seleccionat @msg objects can't be nested more than 32 deep els objectes no es poden niar més de 32 nivells @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path els objectes dins d’una fusió, un embolcall, una repetició o un grup Intertwine no es poden adjuntar a un traçat -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet els objectes amb diversos farciments o traços, efectes o pinzells han arribat amb el seu aspecte dibuixat ({_1}): la seva aparença encara no és editable +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet els objectes amb diversos farciments o traços, traços d’amplada variable, efectes o pinzells han arribat amb el seu aspecte dibuixat ({_1}): la seva aparença encara no és editable @msg Offset Path: select paths Desplaça el traçat: selecciona traçats @msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Desplaça el traçat: el desplaçament no deixa res dels traçats seleccionats (una retracció més gran que les formes) @msg only a dock group above or below others collapses, not one beside another només es plega un grup d’acoblament situat a sobre o a sota d’altres, no un al costat d’un altre diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 34c7f4e03..f9a6263a1 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4885,7 +4885,7 @@ imageTrace Outline Obrys @msg it has {_1}, which VectorCraft doesn't read from it yet obsahuje {_1}, což z něj VectorCraft zatím nečte @msg its objects are nested too deeply jeho objekty jsou vnořené příliš hluboko @msg its section ending with `%{end}` doesn't end jeho oddíl končící `%{end}` nemá konec -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekty s více výplněmi nebo tahy, efekty či štětci byly načteny jako vykreslený vzhled ({_1}): jejich vzhled zatím nelze upravovat +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekty s více výplněmi nebo tahy, tahy s proměnnou šířkou, efekty či štětci byly načteny jako vykreslený vzhled ({_1}): jejich vzhled zatím nelze upravovat @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it přechod „{name}“ není v souboru definován: zastupuje ho plná barva @msg an image VectorCraft can't read was left out obrázek, který VectorCraft neumí přečíst, byl vynechán @msg an image whose data is cut short was left out obrázek s useknutými daty byl vynechán diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 5a2391342..79fb2d383 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4537,7 +4537,7 @@ imageTrace Outline Kontur @msg it has {_1}, which VectorCraft doesn't read from it yet den har {_1}, hvilket VectorCraft endnu ikke læser fra den @msg its objects are nested too deeply dens objekter er indlejret for dybt @msg its section ending with `%{end}` doesn't end dens sektion, der ender med `%{end}`, ender ikke -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekter med flere fyld eller streger, effekter eller pensler kom ind, som de er tegnet ({_1}): deres udseende er endnu ikke redigerbart +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekter med flere fyld eller streger, streger med variabel bredde, effekter eller pensler kom ind, som de er tegnet ({_1}): deres udseende er endnu ikke redigerbart @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it gradienten »{name}« er ikke defineret i filen: der bruges en flad farve i stedet @msg an image VectorCraft can't read was left out et billede, som VectorCraft ikke kan læse, blev udeladt @msg an image whose data is cut short was left out et billede, hvis data er afkortet, blev udeladt diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 101bbefac..c23a6f74e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4494,7 +4494,7 @@ theme Appearance Erscheinungsbild @msg it has {_1}, which VectorCraft doesn't read from it yet es enthält {_1}, was VectorCraft daraus noch nicht liest @msg its objects are nested too deeply seine Objekte sind zu tief verschachtelt @msg its section ending with `%{end}` doesn't end sein Abschnitt, der mit `%{end}` enden soll, endet nicht -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet Objekte mit mehreren Flächen oder Konturen, Effekten oder Pinseln wurden so übernommen, wie sie gezeichnet aussehen ({_1}): Ihr Aussehen ist noch nicht bearbeitbar +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet Objekte mit mehreren Flächen oder Konturen, Konturen mit variabler Breite, Effekten oder Pinseln wurden so übernommen, wie sie gezeichnet aussehen ({_1}): Ihr Aussehen ist noch nicht bearbeitbar @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it der Verlauf „{name}“ ist in der Datei nicht definiert: Eine einfache Farbe steht an seiner Stelle @msg an image VectorCraft can't read was left out ein Bild, das VectorCraft nicht lesen kann, wurde ausgelassen @msg an image whose data is cut short was left out ein Bild mit abgeschnittenen Daten wurde ausgelassen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e704faade..bd8ba5265 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4527,7 +4527,7 @@ digits Malayalam Μαλαγιάλαμ @msg it has {_1}, which VectorCraft doesn't read from it yet περιέχει {_1}, τα οποία το VectorCraft δεν μπορεί ακόμη να διαβάσει @msg its objects are nested too deeply τα αντικείμενά του έχουν υπερβολικά βαθιά ένθεση @msg its section ending with `%{end}` doesn't end η ενότητά του που τελειώνει σε `%{end}` δεν τερματίζεται -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet τα αντικείμενα με πολλαπλά γεμίσματα, περιγράμματα, εφέ ή πινέλα εισήχθησαν ως απεικονίσεις ({_1}): η εμφάνισή τους δεν είναι ακόμη επεξεργάσιμη +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet τα αντικείμενα με πολλαπλά γεμίσματα, περιγράμματα, περιγράμματα μεταβλητού πλάτους, εφέ ή πινέλα εισήχθησαν ως απεικονίσεις ({_1}): η εμφάνισή τους δεν είναι ακόμη επεξεργάσιμη @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it το ντεγκραντέ «{name}» δεν ορίζεται στο αρχείο: χρησιμοποιείται στη θέση του ένα συμπαγές χρώμα @msg an image VectorCraft can't read was left out παραλείφθηκε εικόνα που δεν μπορεί να διαβάσει το VectorCraft @msg an image whose data is cut short was left out παραλείφθηκε εικόνα με περικομμένα δεδομένα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4ebddbe9a..9864c746f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4579,7 +4579,7 @@ axis Both Ambos @msg it has {_1}, which VectorCraft doesn't read from it yet tienen {_1}, que VectorCraft aún no lee de ellos @msg its objects are nested too deeply sus objetos están anidados a demasiada profundidad @msg its section ending with `%{end}` doesn't end su sección que termina con `%{end}` no termina -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet los objetos con varios rellenos o trazos, efectos o pinceles se importaron con su aspecto dibujado ({_1}): su aspecto aún no se puede editar +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet los objetos con varios rellenos o trazos, trazos de ancho variable, efectos o pinceles se importaron con su aspecto dibujado ({_1}): su aspecto aún no se puede editar @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it el degradado “{name}” no está definido en el archivo: lo sustituye un color sólido @msg an image VectorCraft can't read was left out se omitió una imagen que VectorCraft no puede leer @msg an image whose data is cut short was left out se omitió una imagen con datos incompletos diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 7042b21f2..59920bf0e 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4787,7 +4787,7 @@ imageTrace Outline Ääriviiva @msg it has {_1}, which VectorCraft doesn't read from it yet siinä on {_1}, jota VectorCraft ei vielä lue siitä @msg its objects are nested too deeply sen objektit ovat liian syvällä sisäkkäin @msg its section ending with `%{end}` doesn't end sen `%{end}`-merkintään päättyvä osio ei pääty -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objektit, joilla on useita täyttöjä tai viivoja, tehosteita tai siveltimiä, tuotiin piirretyn näköisinä ({_1}): niiden ulkoasua ei voi vielä muokata +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objektit, joilla on useita täyttöjä tai viivoja, vaihtelevan levyisiä viivoja, tehosteita tai siveltimiä, tuotiin piirretyn näköisinä ({_1}): niiden ulkoasua ei voi vielä muokata @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it liukuväriä “{name}” ei ole määritetty tiedostossa: sen tilalla on tasaväri @msg an image VectorCraft can't read was left out kuva, jota VectorCraft ei voi lukea, jätettiin pois @msg an image whose data is cut short was left out kuva, jonka data on katkennut, jätettiin pois diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 48eb621eb..5a6e28f78 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4569,7 +4569,7 @@ axis Both Les deux @msg it has {_1}, which VectorCraft doesn't read from it yet elles contiennent {_1}, que VectorCraft n’y lit pas encore @msg its objects are nested too deeply leurs objets sont imbriqués trop profondément @msg its section ending with `%{end}` doesn't end leur section qui se termine par `%{end}` ne se termine pas -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet les objets à plusieurs fonds ou contours, effets ou formes ont été importés tels qu’ils sont dessinés ({_1}) : leur aspect n’est pas encore modifiable +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet les objets à plusieurs fonds ou contours, contours de largeur variable, effets ou formes ont été importés tels qu’ils sont dessinés ({_1}) : leur aspect n’est pas encore modifiable @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it le dégradé « {name} » n’est pas défini dans le fichier : une couleur unie le remplace @msg an image VectorCraft can't read was left out une image que VectorCraft ne peut pas lire a été ignorée @msg an image whose data is cut short was left out une image aux données tronquées a été ignorée diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 81f948263..f6c92d2ec 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4972,7 +4972,7 @@ imageTrace Outline Körvonal @msg object {0} cannot have children ennek az objektumnak nem lehetnek gyermekei: {0} @msg object {id} is not a path az objektum nem útvonal: {id} @msg object {id} is not a selected top-level object az objektum nem kijelölt legfelső szintű objektum: {id} -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet a több kitöltésű vagy körvonalú, effektusos vagy ecsetes objektumok a megrajzolt kinézetükkel kerültek be ({_1}): a megjelenésük még nem szerkeszthető +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet a több kitöltésű vagy körvonalú, változó szélességű körvonalú, effektusos vagy ecsetes objektumok a megrajzolt kinézetükkel kerültek be ({_1}): a megjelenésük még nem szerkeszthető @msg Offset Path: select paths Útvonal eltolása: jelölj ki útvonalakat @msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Útvonal eltolása: az eltolás után semmi sem marad a kijelölt útvonalakból (a befelé eltolás nagyobb az alakzatoknál) @msg onto must name a floating panel az onto egy lebegő panelt kell megnevezzen diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 89633ae99..eeaebb967 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4537,7 +4537,7 @@ imageTrace Outline Kontur @msg it has {_1}, which VectorCraft doesn't read from it yet memiliki {_1}, yang belum dibaca VectorCraft dari berkas tersebut @msg its objects are nested too deeply objeknya bersarang terlalu dalam @msg its section ending with `%{end}` doesn't end bagian yang berakhir dengan `%{end}` tidak berakhir -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objek dengan beberapa isian atau goresan, efek, atau kuas masuk sebagai tampilan gambarnya ({_1}): penampilannya belum dapat diedit +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objek dengan beberapa isian atau goresan, goresan dengan lebar bervariasi, efek, atau kuas masuk sebagai tampilan gambarnya ({_1}): penampilannya belum dapat diedit @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it gradien “{name}” tidak didefinisikan dalam berkas: warna polos dipakai sebagai pengganti @msg an image VectorCraft can't read was left out gambar yang tidak dapat dibaca VectorCraft tidak disertakan @msg an image whose data is cut short was left out gambar yang datanya terpotong tidak disertakan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6acfaf3c9..dea813c57 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4620,7 +4620,7 @@ axis Both Entrambi @msg it has {_1}, which VectorCraft doesn't read from it yet contengono {_1}, che VectorCraft non vi legge ancora @msg its objects are nested too deeply i loro oggetti sono annidati troppo in profondità @msg its section ending with `%{end}` doesn't end la loro sezione che termina con `%{end}` non termina -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet gli oggetti con più riempimenti o tratti, effetti o pennelli sono stati importati come appaiono disegnati ({_1}): il loro aspetto non è ancora modificabile +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet gli oggetti con più riempimenti o tratti, tratti a larghezza variabile, effetti o pennelli sono stati importati come appaiono disegnati ({_1}): il loro aspetto non è ancora modificabile @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it la sfumatura “{name}” non è definita nel file: la sostituisce un colore pieno @msg an image VectorCraft can't read was left out un'immagine che VectorCraft non può leggere è stata omessa @msg an image whose data is cut short was left out un'immagine con dati incompleti è stata omessa diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4bf3cd6bd..4ab5b6b60 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4327,7 +4327,7 @@ swatch Bright Violet ビビッドバイオレット @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel リンク画像は、元のファイルが見つかるまで、ファイルに保存された低解像度のプレビューで表示されます:リンクパネルで再リンクしてください @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise 表示に使われるフォントの 1 行に対して低すぎるフレーム内のエリア内文字は、フレームを高くしました ({_1}): そうしないと文字が表示されません @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it グラデーション「{name}」はファイル内で定義されていません: 代わりに単色を使用しています -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 塗りや線が複数ある、または効果やブラシを適用したオブジェクト {_1} 個は見た目のまま読み込まれました: アピアランスはまだ編集できません +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 塗りや線が複数ある、可変幅の線がある、または効果やブラシを適用したオブジェクト {_1} 個は見た目のまま読み込まれました: アピアランスはまだ編集できません @msg patterns nested too deeply are filled with mid-grey 入れ子が深すぎるパターンは中間のグレーで塗りつぶされています @msg shadings of an unknown type were left out 種類が不明なシェーディングは除外されました @msg shadings whose functions are of an unknown type are filled with their middle colour 関数の種類が不明なシェーディングは中間の色で塗りつぶされています diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index cf411b072..1d478728e 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4324,7 +4324,7 @@ font-technology Variable ცვლადი @msg it has {_1}, which VectorCraft doesn't read from it yet მას აქვს {_1}, რასაც VectorCraft მისგან ჯერ ვერ კითხულობს @msg its objects are nested too deeply მისი ობიექტები ზედმეტად ღრმადაა ჩადგმული @msg its section ending with `%{end}` doesn't end მისი სექცია, რომელიც `%{end}`-ით მთავრდება, არ სრულდება -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet რამდენიმე შევსების ან კონტურის, ეფექტის ან ფუნჯის მქონე ობიექტები შემოვიდა დახატული სახით ({_1}): მათი იერსახე ჯერ არ რედაქტირდება +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet რამდენიმე შევსების ან კონტურის, ცვლადი სისქის კონტურის, ეფექტის ან ფუნჯის მქონე ობიექტები შემოვიდა დახატული სახით ({_1}): მათი იერსახე ჯერ არ რედაქტირდება @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it გრადიენტი „{name}“ ფაილში განსაზღვრული არ არის: მის ნაცვლად ერთგვაროვანი ფერია @msg an image VectorCraft can't read was left out გამოსახულება, რომელსაც VectorCraft ვერ კითხულობს, გამოტოვებულია @msg an image whose data is cut short was left out გამოსახულება, რომლის მონაცემებიც შეკვეცილია, გამოტოვებულია diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 81f6c600c..8ee22794e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4254,7 +4254,7 @@ theme Appearance 외관 @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel 연결된 그림은 해당 파일을 찾을 때까지 파일에 저장된 저해상도 미리 보기로 표시됩니다: 링크 패널에서 다시 연결하세요 @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise 표시되는 글꼴의 한 줄보다 낮은 프레임의 영역 문자는 프레임을 더 높였습니다({_1}). 그렇지 않으면 문자가 표시되지 않습니다 @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it 그레이디언트 “{name}”이(가) 파일에 정의되어 있지 않아 단색으로 대체했습니다 -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 칠이나 선이 여러 개인 개체, 효과, 브러시가 있는 개체는 그려진 모양({_1})으로 가져왔습니다: 모양새는 아직 편집할 수 없습니다 +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 칠이나 선이 여러 개인 개체, 폭이 변하는 선, 효과, 브러시가 있는 개체는 그려진 모양({_1})으로 가져왔습니다: 모양새는 아직 편집할 수 없습니다 @msg patterns nested too deeply are filled with mid-grey 너무 깊게 중첩된 패턴은 중간 회색으로 채웁니다 @msg shadings of an unknown type were left out 알 수 없는 종류의 셰이딩은 제외했습니다 @msg shadings whose functions are of an unknown type are filled with their middle colour 함수 종류를 알 수 없는 셰이딩은 중간 색상으로 채웁니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 344853683..dd3d5f2e1 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4549,7 +4549,7 @@ imageTrace Outline Contour @msg it has {_1}, which VectorCraft doesn't read from it yet het bevat {_1}, wat VectorCraft er nog niet uit leest @msg its objects are nested too deeply de objecten ervan zijn te diep genest @msg its section ending with `%{end}` doesn't end zijn sectie die op `%{end}` eindigt, eindigt niet -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objecten met meerdere vullingen of lijnen, effecten of penselen zijn als hun getekende uiterlijk binnengekomen ({_1}): hun vormgeving is nog niet bewerkbaar +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objecten met meerdere vullingen of lijnen, lijnen met variabele breedte, effecten of penselen zijn als hun getekende uiterlijk binnengekomen ({_1}): hun vormgeving is nog niet bewerkbaar @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it verloop „{name}” is niet gedefinieerd in het bestand: er is een effen kleur voor gebruikt @msg an image VectorCraft can't read was left out een afbeelding die VectorCraft niet kan lezen, is weggelaten @msg an image whose data is cut short was left out een afbeelding waarvan de gegevens zijn afgekapt, is weggelaten diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 14825f83c..dd89695e4 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4711,7 +4711,7 @@ imageTrace Outline Kontur @msg it has {_1}, which VectorCraft doesn't read from it yet zawiera {_1}, czego VectorCraft jeszcze z niego nie odczytuje @msg its objects are nested too deeply jego obiekty są zagnieżdżone zbyt głęboko @msg its section ending with `%{end}` doesn't end jego sekcja kończąca się na `%{end}` nie ma końca -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet obiekty z kilkoma wypełnieniami lub obrysami, efektami albo pędzlami wczytano jako ich narysowany wygląd ({_1}): ich wyglądu nie można jeszcze edytować +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet obiekty z kilkoma wypełnieniami lub obrysami, obrysami o zmiennej szerokości, efektami albo pędzlami wczytano jako ich narysowany wygląd ({_1}): ich wyglądu nie można jeszcze edytować @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it gradient „{name}” nie jest zdefiniowany w pliku: zastąpiono go jednolitym kolorem @msg an image VectorCraft can't read was left out pominięto obraz, którego VectorCraft nie potrafi odczytać @msg an image whose data is cut short was left out pominięto obraz z uciętymi danymi diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 96a31c3e5..ec78a8fc8 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4150,7 +4150,7 @@ axis Both Ambos @msg it has {_1}, which VectorCraft doesn't read from it yet tem {_1}, que o VectorCraft ainda não lê desse arquivo @msg its objects are nested too deeply seus objetos estão aninhados com profundidade excessiva @msg its section ending with `%{end}` doesn't end sua seção que termina com `%{end}` não termina -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objetos com vários preenchimentos ou traços, efeitos ou pincéis entraram com a aparência desenhada ({_1}): sua aparência ainda não é editável +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objetos com vários preenchimentos ou traços, traços de largura variável, efeitos ou pincéis entraram com a aparência desenhada ({_1}): sua aparência ainda não é editável @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it o gradiente “{name}” não está definido no arquivo: uma cor sólida o substitui @msg an image VectorCraft can't read was left out uma imagem que o VectorCraft não consegue ler foi omitida @msg an image whose data is cut short was left out uma imagem com dados incompletos foi omitida diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index fdb51b80d..2c128ea48 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4615,7 +4615,7 @@ axis Both Оба @msg it has {_1}, which VectorCraft doesn't read from it yet в них есть {_1}, которые VectorCraft пока не читает @msg its objects are nested too deeply объекты в них вложены слишком глубоко @msg its section ending with `%{end}` doesn't end раздел, заканчивающийся на `%{end}`, не заканчивается -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet объекты с несколькими заливками или обводками, эффектами или кистями импортированы в нарисованном виде ({_1}): их оформление пока нельзя редактировать +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet объекты с несколькими заливками или обводками, обводками переменной ширины, эффектами или кистями импортированы в нарисованном виде ({_1}): их оформление пока нельзя редактировать @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it градиент «{name}» не определён в файле: вместо него используется сплошной цвет @msg an image VectorCraft can't read was left out изображение, которое VectorCraft не может прочитать, пропущено @msg an image whose data is cut short was left out изображение с обрезанными данными пропущено diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index e2dddf022..16c4a111c 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4478,7 +4478,7 @@ axis Both Båda @msg it has {_1}, which VectorCraft doesn't read from it yet den innehåller {_1}, som VectorCraft inte kan läsa från den än @msg its objects are nested too deeply dess objekt är för djupt kapslade @msg its section ending with `%{end}` doesn't end dess avsnitt som slutar med `%{end}` avslutas aldrig -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekt med flera fyllningar eller konturer, effekter eller penslar importerades som sitt ritade utseende ({_1}): deras utseende går inte att redigera än +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekt med flera fyllningar eller konturer, konturer med variabel bredd, effekter eller penslar importerades som sitt ritade utseende ({_1}): deras utseende går inte att redigera än @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it gradienten ”{name}” är inte definierad i filen: en enfärgad fyllning används i stället @msg an image VectorCraft can't read was left out en bild som VectorCraft inte kan läsa utelämnades @msg an image whose data is cut short was left out en bild med avkortade data utelämnades diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 9ecf2ccf9..67dbc32ca 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4321,7 +4321,7 @@ axis Both Her İkisi @msg it has {_1}, which VectorCraft doesn't read from it yet VectorCraft'ın henüz okumadığı {_1} var @msg its objects are nested too deeply nesneleri çok derin bir şekilde iç içe geçmiş @msg its section ending with `%{end}` doesn't end `%{end}` ile biten bölümü bitmiyor -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet çeşitli dolgulara veya konturlara, efektlere veya fırçalara sahip nesneler çizilmiş görünümleri olarak geldi ({_1}): görünümleri henüz düzenlenemez +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet çeşitli dolgulara veya konturlara, değişken genişlikli konturlara, efektlere veya fırçalara sahip nesneler çizilmiş görünümleri olarak geldi ({_1}): görünümleri henüz düzenlenemez @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it “{name}” degradesi dosyada tanımlı değil: onun yerine düz bir renk geliyor @msg an image VectorCraft can't read was left out VectorCraft'ın okuyamadığı bir resim dışarıda bırakıldı @msg an image whose data is cut short was left out verileri kısa kesilen bir resim dışarıda bırakıldı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d348440a2..4f719e80e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4604,7 +4604,7 @@ axis Both Обидві @msg it has {_1}, which VectorCraft doesn't read from it yet у них є {_1}, які VectorCraft поки не читає @msg its objects are nested too deeply об’єкти в них вкладені надто глибоко @msg its section ending with `%{end}` doesn't end розділ, що закінчується на `%{end}`, не закінчується -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet об’єкти з кількома заливками або обведеннями, ефектами або пензлями імпортовані в намальованому вигляді ({_1}): їх оформлення поки що не можна редагувати +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet об’єкти з кількома заливками або обведеннями, обведеннями змінної ширини, ефектами або пензлями імпортовані в намальованому вигляді ({_1}): їх оформлення поки що не можна редагувати @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it градієнт «{name}» не визначено у файлі: замість нього використовується суцільний колір @msg an image VectorCraft can't read was left out зображення, яке VectorCraft не може прочитати, пропущено @msg an image whose data is cut short was left out зображення з обрізаними даними пропущено diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 09652ede2..28b52b02e 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4626,7 +4626,7 @@ theme Appearance Giao diện @msg object {0} cannot have children đối tượng {0} không thể có phần tử con @msg object {id} is not a path đối tượng {id} không phải là đường dẫn @msg object {id} is not a selected top-level object đối tượng {id} không phải là đối tượng cấp cao nhất đã chọn -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet đối tượng có nhiều tô hoặc nét, hiệu ứng hoặc cọ đã vào dưới dạng bề ngoài ({_1}): diện mạo chưa thể chỉnh sửa +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet đối tượng có nhiều tô hoặc nét, nét có độ rộng thay đổi, hiệu ứng hoặc cọ đã vào dưới dạng bề ngoài ({_1}): diện mạo chưa thể chỉnh sửa @msg Offset Path: select paths Đường dẫn độ lệch: hãy chọn đường dẫn @msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Đường dẫn độ lệch: độ lệch không để lại gì từ các đường dẫn đã chọn (độ lệch vào trong lớn hơn hình) @msg onto must name a floating panel onto phải đặt tên bảng đang thả nổi diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 841bd4528..29278d6f0 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4050,7 +4050,7 @@ imageTrace Outline 轮廓 @msg an image VectorCraft can't read was left out 已略去 VectorCraft 无法读取的图像 @msg an image whose data is cut short was left out 已略去数据不完整的图像 @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it 文件中未定义渐变“{name}”:已改用纯色代替 -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 带有多个填色或描边、效果或画笔的对象已按其绘制外观导入 ({_1}):其外观尚不可编辑 +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 带有多个填色或描边、可变宽度描边、效果或画笔的对象已按其绘制外观导入 ({_1}):其外观尚不可编辑 @msg patterns nested too deeply are filled with mid-grey 嵌套过深的图案以中灰色填充 @msg shadings of an unknown type were left out 已略去未知类型的着色 @msg shadings whose functions are of an unknown type are filled with their middle colour 函数类型未知的着色以其中间颜色填充 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a515d59a7..f8cb6bacf 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1973,7 +1973,7 @@ file-filter Templates 範本 @msg an image VectorCraft can't read was left out 已略過 VectorCraft 無法讀取的影像 @msg an image whose data is cut short was left out 已略過資料不完整的影像 @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it 檔案中未定義漸層「{name}」:已改用單色替代 -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 具有多個填色或筆畫、效果或筆刷的物件已依其繪製外觀匯入 ({_1}):其外觀目前尚無法編輯 +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 具有多個填色或筆畫、可變寬度筆畫、效果或筆刷的物件已依其繪製外觀匯入 ({_1}):其外觀目前尚無法編輯 @msg patterns nested too deeply are filled with mid-grey 巢狀層級過深的圖樣以中灰色填滿 @msg shadings of an unknown type were left out 已略過類型未知的著色 @msg shadings whose functions are of an unknown type are filled with their middle colour 函數類型未知的著色以其中間顏色填滿 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index de7e1cfce..8c666c11a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -84,7 +84,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); the page's drawing of it (often in pieces) is left out. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient) with an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush, dashes aligned to corners or a variable-width stroke comes in as its drawn look (a group named after it). Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); the page's drawing of it (often in pieces) is left out. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. ## Format by format From 9726e691f527340fe0a8c9c50c30c766f18a2596 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 12:17:42 -0800 Subject: [PATCH 10/71] .ai import: names that aren't UTF-8 can be read in a legacy code page A legacy .ai stores layer, object, color, gradient, pattern, style and swatch names as bytes, without saying which code page they are in. Names that aren't UTF-8 are still read as Windows-1252 by default, and now each gives a warning naming it and the encoding it was read in (the first three, then a count). document.open takes legacyNameEncoding (windows-1252, macintosh, windows-949 or euc-kr, shift_jis, gbk, big5) and vectorcraft-cli convert takes --legacy-name-encoding, to read those names in another code page. Definitions and the fills that name them are read alike, so they still match. Type, comments, dictionary keys and PDF layer names are read as before. encoding_rs becomes a direct dependency of vectorcraft-eps. Part of #2831 --- Cargo.lock | 1 + Cargo.toml | 3 + apps/vectorcraft-cli/src/main.rs | 38 ++- crates/engine/src/cmd/fileio/commands.rs | 2 +- crates/engine/src/cmd/fileio/load.rs | 7 +- crates/engine/src/cmd/fileio/pdfimport.rs | 18 +- .../engine/src/cmd/fileio/tests_epsimport.rs | 35 ++ crates/engine/tests/import_fuzz.rs | 37 +++ crates/eps/Cargo.toml | 2 + crates/eps/src/import.rs | 15 +- crates/eps/src/import/ai.rs | 5 +- crates/eps/src/import/ai/obj.rs | 298 ++++++++++++++++++ crates/eps/src/import/ai/paint.rs | 91 ++++-- crates/eps/src/import/ai/palette.rs | 40 ++- crates/eps/src/import/ai/read.rs | 18 +- crates/eps/src/import/ai/read/directives.rs | 10 +- crates/eps/src/import/ai/read/document.rs | 42 ++- crates/eps/src/import/ai/read/ops.rs | 14 +- crates/eps/src/import/ai/style.rs | 25 +- crates/eps/src/import/ai/style/tests.rs | 4 + crates/eps/src/import/ai/tests.rs | 118 ++++++- crates/eps/src/import/native.rs | 2 +- crates/eps/src/import/native/build.rs | 5 +- crates/eps/src/import/native/layers.rs | 82 +++-- crates/eps/src/import/tests_native.rs | 19 ++ crates/eps/src/lib.rs | 5 +- crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/bs.tsv | 3 + crates/ui-egui/src/i18n/ca.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/da.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/hu.tsv | 3 + crates/ui-egui/src/i18n/id.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/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sv.tsv | 3 + crates/ui-egui/src/i18n/tests.rs | 22 ++ 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/file-format-parity.md | 2 +- 54 files changed, 918 insertions(+), 120 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 692dd5a91..7611568dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6257,6 +6257,7 @@ dependencies = [ name = "vectorcraft-eps" version = "0.20.0" dependencies = [ + "encoding_rs", "fax", "flate2", "hayro-ccitt", diff --git a/Cargo.toml b/Cargo.toml index 3088b469b..5b274663e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,6 +96,9 @@ ruzstd = { version = "0.9.1", default-features = false, features = ["std"] } hayro-ccitt = "0.3" # Substring search for the EPS reader's PostScript `search`. memchr = "2" +# The WHATWG Encoding Standard's legacy encodings: names in legacy .ai files read in the code page +# the user picks (Mac OS Roman, Korean, Japanese, Chinese). +encoding_rs = "0.8.42" jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] } gif = { version = "0.14", default-features = false, features = ["std", "raii_no_panic"] } # CMYK image samples (vectorcraft-doc's cmyk module): the image crate's own TIFF and JPEG diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index b7334586c..fa7b4329d 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -4,7 +4,7 @@ //! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]... //! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}'] | --commands FILE|-]... [--export out.svg]... [--scale 2] //! vectorcraft-cli commands -//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] +//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--legacy-name-encoding ENC] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] //! vectorcraft-cli info FILE //! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5] //! vectorcraft-cli perf [--paths 50000] [--deep] @@ -94,7 +94,7 @@ USAGE: vectorcraft-cli commands Print the command catalogue as JSON. - vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] + vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--legacy-name-encoding ENC] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] Open IN (any readable format) and export OUT in the format its extension picks (see Writable formats). --scale sizes a raster OUT (pixels per point, default 1); other formats leave it out. --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard @@ -107,7 +107,9 @@ USAGE: IN left things out (hidden text, art or layers it could not read; the notes say which) unless --replace-lossy is given. --editing-data opens a .ai or an EPS with editing data from its editing copy even where that draws differently from its PDF part or page (which can be older), as long as - it can be read. A PDF OUT omits the embedded native document unless --preserve-editing is + it can be read. --legacy-name-encoding reads the names in a .ai's or an EPS's editing data + that aren't UTF-8 (layers, objects, colors, swatches) in windows-1252 (the default), + macintosh, windows-949, shift_jis, gbk or big5. A PDF OUT omits the embedded native document unless --preserve-editing is given (the app's Save As PDF still defaults to keeping it). With --preserve-editing, when the attachment is written the result's warnings say so (it is left out, with its own warning, for --artboard or a --range without every artboard). @@ -279,7 +281,19 @@ fn commands() -> Result<(), String> { } fn convert(args: &[String]) -> Result<(), String> { - let valued = ["--scale", "-s", "--artboard", "-a", "--range", "-r", "--decimals", "--ai-version", "--hidden-objects", "--transparent-images"]; + let valued = [ + "--scale", + "-s", + "--artboard", + "-a", + "--range", + "-r", + "--decimals", + "--ai-version", + "--hidden-objects", + "--transparent-images", + "--legacy-name-encoding", + ]; if help_requested(args, &valued) { return help(); } @@ -287,6 +301,9 @@ fn convert(args: &[String]) -> Result<(), String> { let (mut scale, mut artboard, mut range, mut outline_text, mut replace_lossy, mut editing_data, mut preserve_editing) = (1.0f64, None::, None::, false, false, false, false); let mut decimals = None::; + // The encoding of a legacy `.ai`'s names that aren't UTF-8, passed on as `document.open`'s + // `legacyNameEncoding`; the engine checks it. + let mut legacy_names = None::; // Legacy `.ai` options, passed on as the export's own (`aiVersion`, `hiddenObjects`, // `transparentImages`); the engine checks their values. let mut legacy: Vec<(&str, String)> = vec![]; @@ -301,6 +318,9 @@ fn convert(args: &[String]) -> Result<(), String> { "--replace-lossy" => replace_lossy = true, "--editing-data" => editing_data = true, "--preserve-editing" => preserve_editing = true, + "--legacy-name-encoding" => { + legacy_names = Some(it.next().cloned().ok_or("--legacy-name-encoding needs an encoding such as windows-949")?); + } "--ai-version" => legacy.push(("aiVersion", it.next().cloned().ok_or("--ai-version needs 7 or pdf")?)), "--hidden-objects" => legacy.push(("hiddenObjects", it.next().cloned().ok_or("--hidden-objects needs layer, omit or visible")?)), "--transparent-images" => legacy.push(("transparentImages", it.next().cloned().ok_or("--transparent-images needs flatten or omit")?)), @@ -313,8 +333,14 @@ fn convert(args: &[String]) -> Result<(), String> { return Err("a .ait template holds every artboard: --artboard and --range don't apply".into()); } let mut h = Headless::new(); - let opened = if editing_data { - let params = json!({"path": input, "editingData": "always"}); + let opened = if editing_data || legacy_names.is_some() { + let mut params = json!({"path": input}); + if editing_data { + params["editingData"] = json!("always"); + } + if let Some(e) = &legacy_names { + params["legacyNameEncoding"] = json!(e); + } h.call("engine.execute", json!({"command": "document.open", "params": params})).map_err(|e| format!("open {input}: {e}"))? } else { h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))? diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index f80302eee..5277e3841 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). 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), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles and swatches; warnings list the first three such names as read, and count the rest), 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). 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 ), diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 008313069..45209ad34 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -169,7 +169,8 @@ fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warning } let Some(private) = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref()) else { return (doc, warnings, false) }; let outlined = opts.text_as == vectorcraft_pdf::TextAs::Outlines; - let (doc, mut warnings, read) = vectorcraft_eps::layered_ai(&private, doc, warnings, outlined, opts.trust_editing_data, unit); + let (doc, mut warnings, read) = + vectorcraft_eps::layered_ai_with(&private, doc, warnings, outlined, opts.trust_editing_data, unit, opts.legacy_names); // The layers weren't read (or couldn't be): the notes of reading the PDF part still hold. if !read { return (doc, warnings, false); @@ -195,7 +196,7 @@ fn from_editing_data_alone(bytes: &[u8], opts: &LoadOptions) -> Option Result _ if matches!(format.id, "eps" | "ps") || vectorcraft_pdf::is_postscript(bytes) => { let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes))); restore_or_import(editing, || { - let r = vectorcraft_eps::import_trusting(bytes, opts.editing_data, opts.trust_editing_data) + let r = vectorcraft_eps::import_names(bytes, opts.editing_data, opts.trust_editing_data, opts.legacy_names) .map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?; Ok((r.document, r.warnings)) })? diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index 80575c23f..3b120e03c 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -39,6 +39,9 @@ pub struct LoadOptions { /// A `.ai` opens from its editing copy even where that draws differently from its PDF part /// (which can be older than it), with a warning, instead of from the PDF part. pub trust_editing_data: bool, + /// The encoding names in a `.ai` or EPS's editing data are read in when they aren't UTF-8 + /// (`legacyNameEncoding`). + pub legacy_names: vectorcraft_eps::NameEncoding, } impl Default for LoadOptions { @@ -54,6 +57,7 @@ impl Default for LoadOptions { dxf: Default::default(), editing_data: true, trust_editing_data: false, + legacy_names: vectorcraft_eps::NameEncoding::default(), } } } @@ -61,8 +65,9 @@ impl Default for LoadOptions { impl LoadOptions { /// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when /// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`) with `grays`, - /// `textAs` (`text` | `outlines`), `layers` (true | false), `editingData` (true | false | `always`) and `dxf` - /// (the DXF import options). + /// `textAs` (`text` | `outlines`), `layers` (true | false), `editingData` (true | false | `always`), + /// `legacyNameEncoding` (one of [`vectorcraft_eps::NameEncoding::LABELS`]) and `dxf` (the DXF + /// import options). pub fn from_params(cmd: &str, p: &Value) -> Result { let pages = match p.get("pages").filter(|v| !v.is_null()).or_else(|| p.get("page").filter(|v| !v.is_null())) { None => None, @@ -90,8 +95,15 @@ impl LoadOptions { Some(Value::String(s)) if s.eq_ignore_ascii_case("always") => (true, true), Some(v) => (v.as_bool().ok_or_else(|| bad(cmd, "editingData must be true, false or \"always\""))?, false), }; + let legacy_names = match p.get("legacyNameEncoding").filter(|v| !v.is_null()) { + None => vectorcraft_eps::NameEncoding::default(), + Some(v) => v + .as_str() + .and_then(vectorcraft_eps::NameEncoding::from_label) + .ok_or_else(|| bad(cmd, format!("legacyNameEncoding must be one of {}", vectorcraft_eps::NameEncoding::LABELS.join(", "))))?, + }; let dxf = super::dxfimport::options(cmd, p.get("dxf"))?; - Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf, editing_data, trust_editing_data }) + Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf, editing_data, trust_editing_data, legacy_names }) } /// Does the document read only part of the file, or read it differently (a page range, another diff --git a/crates/engine/src/cmd/fileio/tests_epsimport.rs b/crates/engine/src/cmd/fileio/tests_epsimport.rs index 8c4141902..59caf85d6 100644 --- a/crates/engine/src/cmd/fileio/tests_epsimport.rs +++ b/crates/engine/src/cmd/fileio/tests_epsimport.rs @@ -184,6 +184,41 @@ showpage assert!(e.contains("junk.eps"), "{e}"); } +/// `legacyNameEncoding` picks the code page of a legacy `.ai`'s layer names that aren't UTF-8; by +/// default they come in as Windows-1252, with a warning naming them. An encoding it doesn't know is +/// refused. +#[test] +fn legacy_ai_layer_names_open_in_the_encoding_picked() { + let mut s = Session::new(); + // A layer named 레이어 1 in Windows code page 949. + let ai = b"%!PS-Adobe-3.0\n%%BoundingBox: 0 0 100 100\n%%DocumentNeededResources: procset Adobe_IllustratorA_AI3 1.0 0\n%AI3_TemplateBox: 50 50 50 50\n%%EndComments\n\ + %%BeginProlog\n%%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 0\n%%EndProlog\n%%BeginSetup\nAdobe_IllustratorA_AI3 /initialize get exec\n%%EndSetup\n\ + %AI5_BeginLayer\n1 1 1 1 0 0 2 0 0 0 Lb\n(\\267\\271\\300\\314\\276\\356 1) Ln\n0 0 0 1 K\n0 i 1 w\n10 10 m\n90 90 L\nS\nLB\n%AI5_EndLayer--\n%%PageTrailer\n%%Trailer\n%%EOF\n"; + let open_as = |s: &mut Session, encoding: Option<&str>| { + let mut p = json!({"name": "korean.ai", "dataBase64": vectorcraft_format::base64_encode(ai)}); + if let Some(e) = encoding { + p["legacyNameEncoding"] = json!(e); + } + s.execute("document.open", &p) + }; + let r = open_as(&mut s, None).unwrap(); + assert_eq!(s.doc().unwrap().doc.layers[0].name.as_deref(), Some("·¹À̾î 1")); + assert_eq!( + r["warnings"], + json!(["the name `·¹À̾î 1` isn't UTF-8 and was read as windows-1252: if it looks wrong, open the file with another legacyNameEncoding"]) + ); + for encoding in ["windows-949", "EUC-KR"] { + let r = open_as(&mut s, Some(encoding)).unwrap(); + assert_eq!(s.doc().unwrap().doc.layers[0].name.as_deref(), Some("레이어 1"), "{encoding}"); + assert!(r["warnings"][0].as_str().is_some_and(|w| w.contains("`레이어 1`") && w.contains("windows-949")), "{r}"); + } + let e = open_as(&mut s, Some("utf-16")).unwrap_err(); + assert!( + matches!(&e, EngineError::BadParams { msg, .. } if msg == "legacyNameEncoding must be one of windows-1252, macintosh, windows-949, shift_jis, gbk, big5"), + "{e}" + ); +} + #[test] fn eps_files_place_as_one_group_clipped_to_their_bounding_box() { let mut s = rich(); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 19c6ef93a..49fc32fb2 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2911,6 +2911,23 @@ fn the_shown_stories_fixture_opens_with_its_layers() { assert!(texts.iter().all(|t| t.contains('\u{2028}') && t.contains('\n')), "{texts:?}"); } +/// The fuzzed editing data with its layer, pattern and global colour named in code page 949 opens +/// with those names, read in that encoding, from its layers. +#[test] +fn the_editing_fixture_reads_names_in_a_legacy_encoding() { + // 레이어 1, then 빨강. + let text = editing_text() + .replace("(One) Ln", r"(\267\271\300\314\276\356 1) Ln") + .replace("(Dots)", r"(\273\241\260\255)") + .replace("(Black global)", r"(\273\241\260\255 global)"); + let bytes = editing_eps(&text); + let r = vectorcraft_eps::import_names(&bytes, true, true, vectorcraft_eps::NameEncoding::Korean).unwrap(); + let names: Vec<_> = r.document.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect(); + assert_eq!(names, ["레이어 1", "Two"], "{:?}", r.warnings); + assert!(r.document.pattern("빨강").is_some(), "{:?}", r.warnings); + assert!(r.document.swatch("빨강 global").is_some_and(|s| s.global), "{:?}", r.warnings); +} + proptest! { #![proptest_config(config())] @@ -2954,6 +2971,26 @@ proptest! { survive("mutated EPS editing data", || vectorcraft_eps::import(&bytes).ok().map(|r| r.document))?; } + /// Names of any bytes (a layer's, a pattern's, a global colour's) read in any legacy name + /// encoding: each byte written as an octal escape, so every code page sees bytes it doesn't + /// define, cut sequences and UTF-8 alike. + #[test] + fn eps_editing_data_names_in_any_encoding_never_panic( + name in prop::collection::vec(any::(), 0..24), + encoding in prop::sample::select(vectorcraft_eps::NameEncoding::LABELS.to_vec()), + ) { + let escaped: String = name.iter().map(|b| format!("\\{b:03o}")).collect(); + let text = editing_text() + .replace("(One) Ln", &format!("({escaped}) Ln")) + .replace("(Dots)", &format!("({escaped})")) + .replace("(Black global)", &format!("({escaped} global)")); + let bytes = editing_eps(&text); + let encoding = vectorcraft_eps::NameEncoding::from_label(encoding).unwrap(); + survive("EPS editing data names in a legacy encoding", || { + vectorcraft_eps::import_names(&bytes, true, false, encoding).ok().map(|r| r.document) + })?; + } + /// A `.ai` whose envelopes are damaged or hostile: any kind, warp style, options word, /// Fidelity and grid size, corners out of range or repeated, the contents block cut short. #[test] diff --git a/crates/eps/Cargo.toml b/crates/eps/Cargo.toml index 6743c2a2a..bbd42fc0f 100644 --- a/crates/eps/Cargo.toml +++ b/crates/eps/Cargo.toml @@ -31,6 +31,8 @@ hayro-ccitt = { workspace = true } serde_json = { workspace = true } # Substring search for PostScript `search`. memchr = { workspace = true } +# Names in legacy .ai files that aren't UTF-8, read in the encoding the user picks. +encoding_rs = { workspace = true } [dev-dependencies] vectorcraft-testkit = { workspace = true } diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index 5a7a9b491..b7ca1a7a5 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -69,7 +69,8 @@ use graphics::{GState, Out}; use interp::{Fault, Interp}; use obj::PsError; -pub use native::{ai_alone, is_loss, layered_ai}; +pub use ai::NameEncoding; +pub use native::{ai_alone, ai_alone_with, is_loss, layered_ai, layered_ai_with}; pub use text::family_style; /// The page of a PostScript file without a bounding box (US Letter). @@ -239,6 +240,12 @@ pub fn import_with(bytes: &[u8], editing_data: bool) -> Result /// [`import_with`]; `trust`: an Illustrator EPS's layers are used even where they draw differently /// from its page, with a note (`editingData: "always"`, as for a `.ai`). pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result { + import_names(bytes, editing_data, trust, NameEncoding::default()) +} + +/// [`import_trusting`], with the names in the editing copy that aren't UTF-8 (layers, objects, +/// colors, gradients, patterns, graphic styles, swatches) read in `names`. +pub fn import_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding) -> Result { let ps = program(bytes)?; let dsc = Dsc::read(ps); // A legacy `.ai` VectorCraft wrote: its prolog prints the art plainly (gradients flat, no @@ -246,7 +253,7 @@ pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result< // printed). if editing_data && dsc.own_legacy - && let Some(Ok((document, warnings))) = native::program_alone(ps, dsc.illustrator) + && let Some(Ok((document, warnings))) = native::program_alone(ps, dsc.illustrator, names) { return Ok(Imported { document, warnings, preview: false }); } @@ -266,7 +273,7 @@ pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result< } let drew = !out.drawn.is_empty(); let why = match (&result, drew) { - (Ok(()), true) if editing_data => return Ok(native::layered(ps, finish(out), trust)), + (Ok(()), true) if editing_data => return Ok(native::layered(ps, finish(out), trust, names)), (Ok(()), true) => return Ok(finish(out)), (Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(), (Err(e), _) => reason(e, fault.as_ref()), @@ -274,7 +281,7 @@ pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result< // A file in the legacy Illustrator format whose program can't be run (a `.ai` that names the // prolog defining its operators without including it): its layers, which the program has, or // its art on one layer when it has none (Illustrator 3). - let why = match editing_data.then(|| native::program_alone(ps, dsc.illustrator)).flatten() { + let why = match editing_data.then(|| native::program_alone(ps, dsc.illustrator, names)).flatten() { Some(Ok((document, warnings))) => return Ok(Imported { document, warnings, preview: false }), Some(Err(e)) => format!("{why}; its layers couldn't be read either ({e})"), None => why, diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index c32bf2217..f7fa9ec71 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -35,7 +35,10 @@ use crate::ps; pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN; pub(crate) use frames::text_frames; -pub use read::{IMAGE_TOO_LARGE, PaletteSwatches, Structure, artboards, palette, read, variables}; +pub use obj::NameEncoding; +#[cfg(test)] +pub use read::read; +pub use read::{IMAGE_TOO_LARGE, PaletteSwatches, Structure, artboards, palette, read_with, variables}; pub(crate) use read::{join_and, slot_frame, slot_of}; #[cfg(test)] diff --git a/crates/eps/src/import/ai/obj.rs b/crates/eps/src/import/ai/obj.rs index 10027f010..98be86a65 100644 --- a/crates/eps/src/import/ai/obj.rs +++ b/crates/eps/src/import/ai/obj.rs @@ -47,6 +47,201 @@ pub(super) fn text(bytes: &[u8]) -> String { } } +/// The encoding names in the editing data that aren't UTF-8 are read in (layers, objects, +/// artboards, colors, gradients, patterns, graphic styles, swatches). Legacy files store them in +/// the code page of the system that wrote them and don't say which. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum NameEncoding { + /// Windows-1252, Latin-1 for its gaps. + #[default] + Windows1252, + /// Mac OS Roman. + Macintosh, + /// Korean: Windows code page 949 (EUC-KR and Unified Hangul Code). + Korean, + /// Japanese: Shift_JIS (Windows code page 932). + ShiftJis, + /// Simplified Chinese: GBK (Windows code page 936). + Gbk, + /// Traditional Chinese: Big5 (Windows code page 950). + Big5, +} + +impl NameEncoding { + /// The labels [`NameEncoding::from_label`] reads, one for each encoding. + pub const LABELS: [&str; 6] = ["windows-1252", "macintosh", "windows-949", "shift_jis", "gbk", "big5"]; + + /// The encoding with label `label` (any case): one of [`Self::LABELS`], or `euc-kr` for + /// [`NameEncoding::Korean`]. + pub fn from_label(label: &str) -> Option { + let label = label.trim().to_ascii_lowercase(); + Some(match label.as_str() { + "windows-1252" => Self::Windows1252, + "macintosh" => Self::Macintosh, + "windows-949" | "euc-kr" => Self::Korean, + "shift_jis" => Self::ShiftJis, + "gbk" => Self::Gbk, + "big5" => Self::Big5, + _ => return None, + }) + } + + /// The encoding's label, as [`Self::LABELS`] gives it. + pub fn label(self) -> &'static str { + match self { + Self::Windows1252 => "windows-1252", + Self::Macintosh => "macintosh", + Self::Korean => "windows-949", + Self::ShiftJis => "shift_jis", + Self::Gbk => "gbk", + Self::Big5 => "big5", + } + } + + /// `bytes` as text: UTF-8 as it is, else read in this encoding, with each sequence it doesn't + /// define as U+FFFD (Windows-1252 defines every byte). + fn decode(self, bytes: &[u8]) -> Decoded { + if let Ok(s) = std::str::from_utf8(bytes) { + return Decoded { text: s.to_string(), fallback: false, replaced: false }; + } + let other = match self { + Self::Windows1252 => return Decoded { text: bytes.iter().map(|b| cp1252(*b)).collect(), fallback: true, replaced: false }, + Self::Macintosh => encoding_rs::MACINTOSH, + Self::Korean => encoding_rs::EUC_KR, + Self::ShiftJis => encoding_rs::SHIFT_JIS, + Self::Gbk => encoding_rs::GBK, + Self::Big5 => encoding_rs::BIG5, + }; + let (text, replaced) = other.decode_without_bom_handling(bytes); + Decoded { text: text.into_owned(), fallback: true, replaced } + } +} + +/// A name's hash, for counting the names past the notes. +fn hash(name: &str) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + name.hash(&mut h); + h.finish() +} + +/// Bytes read as text by [`NameEncoding::decode`]. +struct Decoded { + text: String, + /// The bytes weren't UTF-8. + fallback: bool, + /// Some weren't valid in the encoding either, and became U+FFFD. + replaced: bool, +} + +/// Most names that weren't UTF-8 each named in a note; the rest are counted in one. +const MAX_NAME_NOTES: usize = 3; +/// Most other names counted (the count stops there). +const MAX_NAMES_COUNTED: usize = 10_000; + +/// The names read in a [`NameEncoding`], and the notes on those that weren't UTF-8: one for each +/// name (the first [`MAX_NAME_NOTES`]), then one counting the others (up to +/// [`MAX_NAMES_COUNTED`]). +#[derive(Debug, Default)] +pub(super) struct Names { + pub encoding: NameEncoding, + noted: Vec<(String, bool)>, + /// Hashes of the other names. + more: std::collections::HashSet, +} + +impl Names { + pub fn new(encoding: NameEncoding) -> Self { + Self { encoding, ..Self::default() } + } + + /// A string's or name's text as a name: a string that isn't UTF-8 read in the encoding, + /// with a note. + pub fn text(&mut self, v: &V) -> Option { + match v { + V::Str(s) => Some(self.bytes(s)), + V::Name(n) => Some(n.clone()), + _ => None, + } + } + + /// `bytes` read as a name (see [`Names::text`]). + pub fn bytes(&mut self, bytes: &[u8]) -> String { + let d = self.encoding.decode(bytes); + if d.fallback { + self.note(&d.text, d.replaced); + } + d.text + } + + /// The first text value of `o`'s entry `key`, as a name. + pub fn entry(&mut self, o: &Obj, key: &str) -> Option { + let v = o.get(key)?.iter().find(|v| matches!(v, V::Str(_)))?; + self.text(v) + } + + fn note(&mut self, name: &str, replaced: bool) { + if self.noted.iter().any(|(n, _)| n == name) { + return; + } + if self.noted.len() < MAX_NAME_NOTES { + self.noted.push((name.to_string(), replaced)); + } else { + self.count(hash(name)); + } + } + + fn count(&mut self, h: u64) { + if self.more.len() < MAX_NAMES_COUNTED { + self.more.insert(h); + } + } + + /// Take the notes of `other`, a reader's for part of the same data. + pub fn absorb(&mut self, other: Names) { + for (name, replaced) in other.noted { + self.note(&name, replaced); + } + for h in other.more { + self.count(h); + } + } + + /// The notes on the names that weren't UTF-8. + pub fn notes(&self) -> Vec { + self.notes_of(self.noted.iter().collect(), self.more.len()) + } + + /// The notes on the names in `used` that weren't UTF-8 (names read but then not used, such as + /// the layers of data whose layers aren't used, left out). + pub fn notes_among(&self, used: &[&str]) -> Vec { + let noted: Vec<&(String, bool)> = self.noted.iter().filter(|(n, _)| used.contains(&n.as_str())).collect(); + let others: std::collections::HashSet = + used.iter().filter(|n| !noted.iter().any(|(m, _)| m == *n)).map(|n| hash(n)).filter(|h| self.more.contains(h)).collect(); + self.notes_of(noted, others.len()) + } + + fn notes_of(&self, noted: Vec<&(String, bool)>, more: usize) -> Vec { + let label = self.encoding.label(); + let mut out: Vec = noted + .into_iter() + .map(|(name, replaced)| { + if *replaced { + format!( + "the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding" + ) + } else { + format!("the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding") + } + }) + .collect(); + if more > 0 { + out.push(format!("more names that aren't UTF-8 were read as {label} ({count})", count = more)); + } + out + } +} + fn cp1252(b: u8) -> char { const HIGH: [char; 32] = [ '€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8d}', 'Ž', '\u{8f}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—', @@ -172,4 +367,107 @@ mod tests { assert_eq!(text(b"caf\xe9 \x80"), "café €"); assert_eq!(text("ü".as_bytes()), "ü"); } + + /// The default reads every byte as Windows-1252 does, the five bytes it leaves undefined as the + /// C1 controls of Latin-1, as `text` always has. + #[test] + fn names_default_to_windows_1252_with_latin_1_in_its_gaps() { + let all: Vec = (0x80..=0xff).collect(); + let mut names = Names::default(); + assert_eq!(names.bytes(&all), text(&all)); + let gaps: String = [0x81u8, 0x8d, 0x8f, 0x90, 0x9d].iter().map(|b| names.bytes(&[*b])).collect(); + assert_eq!(gaps, "\u{81}\u{8d}\u{8f}\u{90}\u{9d}"); + assert_eq!(names.bytes(b"\x80\x9f"), "€Ÿ"); + } + + /// A name that is UTF-8 is read as it is in every encoding, without a note. + #[test] + fn utf8_names_need_no_encoding() { + for label in NameEncoding::LABELS { + let mut names = Names::new(NameEncoding::from_label(label).unwrap()); + assert_eq!(names.bytes("레이어 1".as_bytes()), "레이어 1"); + assert_eq!(names.text(&V::Str(b"Layer 1".to_vec())).as_deref(), Some("Layer 1")); + assert!(names.notes().is_empty(), "{label}"); + } + } + + /// Each encoding reads the bytes of a name written in it. + #[test] + fn names_in_each_encoding() { + for (label, bytes, want) in [ + ("windows-1252", &b"Caf\xe9"[..], "Café"), + ("macintosh", b"Caf\x8e", "Café"), + ("windows-949", b"\xb7\xb9\xc0\xcc\xbe\xee 1", "레이어 1"), + ("euc-kr", b"\xb7\xb9\xc0\xcc\xbe\xee 1", "레이어 1"), + ("shift_jis", b"\x83\x8c\x83\x43\x83\x84\x81\x5b 1", "レイヤー 1"), + ("gbk", b"\xcd\xbc\xb2\xe3 1", "图层 1"), + ("big5", b"\xb9\xcf\xbch 1", "圖層 1"), + ] { + let mut names = Names::new(NameEncoding::from_label(label).unwrap()); + assert_eq!(names.bytes(bytes), want, "{label}"); + assert_eq!( + names.notes(), + vec![format!( + "the name `{want}` isn't UTF-8 and was read as {}: if it looks wrong, open the file with another legacyNameEncoding", + names.encoding.label() + )] + ); + } + assert_eq!(NameEncoding::from_label(" Windows-949 "), Some(NameEncoding::Korean)); + assert_eq!(NameEncoding::from_label("utf-8"), None); + for label in NameEncoding::LABELS { + assert_eq!(NameEncoding::from_label(label).map(NameEncoding::label), Some(label)); + } + } + + /// The Korean bytes read by default come in as Windows-1252, with a note naming what they + /// became. + #[test] + fn korean_bytes_by_default_come_in_as_windows_1252_with_a_note() { + let mut names = Names::default(); + let name = names.bytes(b"\xb7\xb9\xc0\xcc\xbe\xee 1"); + assert_eq!(name, "·¹À̾î 1"); + assert_eq!( + names.notes(), + vec!["the name `·¹À̾î 1` isn't UTF-8 and was read as windows-1252: if it looks wrong, open the file with another legacyNameEncoding"] + ); + } + + /// Bytes the encoding doesn't define come in as U+FFFD, and the note says so. + #[test] + fn malformed_names_keep_a_replacement_character() { + let mut names = Names::new(NameEncoding::Korean); + let name = names.bytes(b"\xb7\xb9\xc0 1"); + assert!(name.contains('\u{fffd}'), "{name:?}"); + assert!(name.starts_with('레'), "{name:?}"); + let notes = names.notes(); + assert_eq!(notes.len(), 1); + assert!(notes[0].contains("isn't UTF-8, and parts of it aren't valid windows-949 either:"), "{notes:?}"); + } + + /// The notes name the first names once each, and count the others. + #[test] + fn name_notes_are_capped() { + let mut names = Names::new(NameEncoding::Macintosh); + for i in 0..10u8 { + let bytes = [b'a' + i, 0x8e]; + names.bytes(&bytes); + names.bytes(&bytes); + } + let notes = names.notes(); + assert_eq!(notes.len(), MAX_NAME_NOTES + 1); + assert!(notes[0].contains("`aé`"), "{notes:?}"); + assert_eq!(notes.last().map(String::as_str), Some("more names that aren't UTF-8 were read as macintosh (7)")); + let mut all = Names::new(NameEncoding::Macintosh); + all.absorb(names); + assert_eq!(all.notes(), notes); + // The count stops at its limit, whatever the file holds. + let mut many = Names::new(NameEncoding::Macintosh); + for i in 0..MAX_NAME_NOTES + MAX_NAMES_COUNTED + 50 { + let mut bytes = i.to_string().into_bytes(); + bytes.push(0x8e); + many.bytes(&bytes); + } + assert_eq!(many.notes().last().map(String::as_str), Some("more names that aren't UTF-8 were read as macintosh (10000)")); + } } diff --git a/crates/eps/src/import/ai/paint.rs b/crates/eps/src/import/ai/paint.rs index b624a2247..4231bade1 100644 --- a/crates/eps/src/import/ai/paint.rs +++ b/crates/eps/src/import/ai/paint.rs @@ -27,7 +27,7 @@ use kurbo::{Affine, Point, Vec2}; use vectorcraft_color::gradient::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; use vectorcraft_color::{Color, Paint}; -use super::obj::V; +use super::obj::{Names, V}; /// Largest number of stops read in one gradient. const MAX_STOPS: usize = 256; @@ -44,8 +44,8 @@ pub(super) struct Named { } /// The paint of a colour operator's operands (`op`: its name, lower case or not), with the named -/// colour it uses. `None` when the operands don't fit. -pub(super) fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option)> { +/// colour it uses, its name read by `names`. `None` when the operands don't fit. +pub(super) fn color_op_in(op: &str, vals: &[V], names: &mut Names) -> Option<(Paint, Option)> { let nums: Vec = vals.iter().filter_map(V::num).map(|v| v as f32).collect(); let unit = |v: f32| v.clamp(0.0, 1.0); let last = |n: usize| nums.get(nums.len().checked_sub(n)?..); @@ -59,9 +59,9 @@ pub(super) fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option)> { let [r, g, b] = last(3)? else { return None }; Some((Paint::solid(Color::rgb(unit(*r), unit(*g), unit(*b))), None)) } - "x" => named(vals, Some(false)), - "xx" => named(vals, None), - "xk" => named(vals, None).map(|(p, n)| (p, n.map(|n| Named { spot: false, ..n }))), + "x" => named(vals, Some(false), names), + "xx" => named(vals, None, names), + "xk" => named(vals, None, names).map(|(p, n)| (p, n.map(|n| Named { spot: false, ..n }))), "xs" | "xz" => registration(vals), _ => None, } @@ -69,9 +69,9 @@ pub(super) fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option)> { /// `c m y k (name) tint x` or `c m y k [r g b | L a b] (name) tint type Xx` (type 1: RGB, 2: Lab); /// `rgb` forces RGB or CMYK. -fn named(vals: &[V], rgb: Option) -> Option<(Paint, Option)> { +fn named(vals: &[V], rgb: Option, names: &mut Names) -> Option<(Paint, Option)> { let at = vals.iter().rposition(|v| matches!(v, V::Str(_)))?; - let name = vals.get(at)?.text()?; + let name = vals.get(at)?; let before: Vec = vals.get(..at)?.iter().filter_map(V::num).map(|v| v as f32).collect(); let after: Vec = vals.get(at + 1..)?.iter().filter_map(V::num).map(|v| v as f32).collect(); let unit = |v: f32| v.clamp(0.0, 1.0); @@ -91,6 +91,7 @@ fn named(vals: &[V], rgb: Option) -> Option<(Paint, Option)> { let [c, m, y, k] = before.get(from..from + 4)? else { return None }; Color::cmyk(unit(*c), unit(*m), unit(*y), unit(*k)) }; + let name = names.text(name)?; let paint = Paint::Solid { color: color.tinted(tint), swatch: Some(name.clone()), tint }; Some((paint, Some(Named { name, color, tint, spot: true }))) } @@ -105,7 +106,7 @@ fn registration(vals: &[V]) -> Option<(Paint, Option)> { } /// The colour of a gradient stop's colour values and style. -fn stop_color(style: f64, comps: &[V]) -> Option<(Color, Option)> { +fn stop_color(style: f64, comps: &[V], names: &mut Names) -> Option<(Color, Option)> { let nums: Vec = comps.iter().filter_map(V::num).map(|v| v as f32).collect(); let unit = |v: f32| v.clamp(0.0, 1.0); let tail = |n: usize| nums.get(nums.len().checked_sub(n)?..); @@ -120,7 +121,7 @@ fn stop_color(style: f64, comps: &[V]) -> Option<(Color, Option)> { (Color::rgb(unit(*r), unit(*g), unit(*b)), None) } _ => { - let (paint, named) = named(comps, Some(style as i64 == 4))?; + let (paint, named) = named(comps, Some(style as i64 == 4), names)?; (paint.color()?, named) } }) @@ -134,16 +135,16 @@ pub(super) struct GradientDef { } impl GradientDef { - /// `(name) type stops Bd`. - pub fn begin(vals: &[V]) -> Option { - let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None })?; + /// `(name) type stops Bd`, the name read by `names`. + pub fn begin(vals: &[V], names: &mut Names) -> Option { + let name = vals.iter().find(|v| matches!(v, V::Str(_))).and_then(|v| names.text(v))?; let nums: Vec = vals.iter().filter_map(V::num).collect(); let kind = if nums.first().copied() == Some(1.0) { GradientKind::Radial } else { GradientKind::Linear }; Some(Self { name, gradient: Gradient::new(kind, vec![]) }) } - /// A stop (`Bs`). - pub fn stop(&mut self, vals: &[V], spots: &mut Vec) { + /// A stop (`Bs`), the name of its colour read by `names`. + pub fn stop(&mut self, vals: &[V], spots: &mut Vec, names: &mut Names) { if self.gradient.stops.len() >= MAX_STOPS { return; } @@ -154,7 +155,7 @@ impl GradientDef { let newer = n >= 5 && n.checked_sub(3).and_then(num) == Some(6.0); let (style_at, opacity) = if newer { (n.checked_sub(5), n.checked_sub(4).and_then(num).unwrap_or(1.0)) } else { (n.checked_sub(3), 1.0) }; let (Some(style_at), Some(style)) = (style_at, style_at.and_then(num)) else { return }; - let Some((color, named)) = stop_color(style, vals.get(..style_at).unwrap_or_default()) else { return }; + let Some((color, named)) = stop_color(style, vals.get(..style_at).unwrap_or_default(), names) else { return }; let mut stop = GradientStop::new((ramp / 100.0).clamp(0.0, 1.0) as f32, color); stop.opacity = opacity.clamp(0.0, 1.0) as f32; stop.midpoint = (mid / 100.0).clamp(0.13, 0.87) as f32; @@ -211,10 +212,10 @@ impl Instance { Self { stroke, length: 1.0, ..Self::default() } } - /// `flag (name) x y angle length a b c d tx ty Bg`. - pub fn bg(&mut self, vals: &[V]) { + /// `flag (name) x y angle length a b c d tx ty Bg`, the name read by `names`. + pub fn bg(&mut self, vals: &[V], names: &mut Names) { let Some(at) = vals.iter().rposition(|v| matches!(v, V::Str(_))) else { return }; - self.name = vals.get(at).and_then(V::text); + self.name = vals.get(at).and_then(|v| names.text(v)); let n: Vec = vals.get(at + 1..).unwrap_or_default().iter().filter_map(V::num).collect(); if let [x, y, angle, length, m @ ..] = n.as_slice() { self.origin = Point::new(*x, *y); @@ -333,6 +334,35 @@ mod tests { v.iter().map(|n| V::Num(*n)).collect() } + fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option)> { + color_op_in(op, vals, &mut Names::default()) + } + + /// A named colour, a gradient and its stop's colour, and the gradient an object names, read in + /// the encoding picked, so that names written alike match. + #[test] + fn colour_and_gradient_names_read_in_the_encoding() { + let mut names = Names::new(super::super::obj::NameEncoding::Korean); + let mut v = nums(&[0.0, 0.5, 1.0, 0.0]); + v.extend([V::Str(b"\xbb\xa1\xb0\xad".to_vec()), V::Num(0.0)]); + let (_, named) = color_op_in("x", &v, &mut names).unwrap(); + assert_eq!(named.unwrap().name, "빨강"); + let mut d = GradientDef::begin(&[V::Str(b"\xb1\xd7\xb6\xf3".to_vec()), V::Num(0.0)], &mut names).unwrap(); + let mut stop = nums(&[0.0, 0.5, 1.0, 0.0]); + stop.extend([V::Str(b"\xbb\xa1\xb0\xad".to_vec()), V::Num(0.0), V::Num(3.0), V::Num(50.0), V::Num(0.0)]); + let mut spots = vec![]; + d.stop(&stop, &mut spots, &mut names); + assert_eq!(spots.first().map(|s| s.name.as_str()), Some("빨강")); + let (name, g) = d.finish().unwrap(); + assert_eq!(name, "그라"); + let mut i = Instance::begin(&[]); + let mut bg = vec![V::Num(1.0), V::Str(b"\xb1\xd7\xb6\xf3".to_vec())]; + bg.extend(nums(&[0.0, 0.0, 0.0, 10.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); + i.bg(&bg, &mut names); + assert!(i.paint(&HashMap::from([(name, g)]), Affine::IDENTITY).is_some()); + assert_eq!(names.notes().len(), 2); + } + #[test] fn registration_paints_with_the_registration_swatch_at_its_tint() { let mut v = nums(&[0.75, 0.68, 0.67, 0.9, 0.0, 0.0, 0.0]); @@ -390,10 +420,11 @@ mod tests { #[test] fn gradient_definition_and_instance() { - let mut d = GradientDef::begin(&[V::Str(b"G".to_vec()), V::Num(1.0), V::Num(2.0)]).unwrap(); + let names = &mut Names::default(); + let mut d = GradientDef::begin(&[V::Str(b"G".to_vec()), V::Num(1.0), V::Num(2.0)], names).unwrap(); let mut spots = vec![]; - d.stop(&nums(&[0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 2.0, 0.5, 6.0, 30.0, 100.0]), &mut spots); - d.stop(&nums(&[0.81, 0.0, 1.0, 6.0, 50.0, 0.0]), &mut spots); + d.stop(&nums(&[0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 2.0, 0.5, 6.0, 30.0, 100.0]), &mut spots, names); + d.stop(&nums(&[0.81, 0.0, 1.0, 6.0, 50.0, 0.0]), &mut spots, names); let (name, g) = d.finish().unwrap(); assert_eq!(name, "G"); assert_eq!(g.kind, GradientKind::Radial); @@ -404,7 +435,7 @@ mod tests { let mut i = Instance::begin(&[]); let mut bg = vec![V::Num(1.0), V::Str(b"G".to_vec())]; bg.extend(nums(&[100.0, 100.0, 0.0, 50.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); - i.bg(&bg); + i.bg(&bg, names); i.bm(&nums(&[50.0, 0.0, 0.0, -50.0, 100.0, 100.0])); let Some(Paint::Gradient(gp)) = i.paint(&defs, Affine::IDENTITY) else { panic!() }; let geom = gp.geom.unwrap(); @@ -414,7 +445,7 @@ mod tests { assert!(i.paint(&HashMap::new(), Affine::IDENTITY).is_none()); // Newer files give the matrix with `Xm`; the `Bm`s after it are bands of a cached ramp. let mut i = Instance::begin(&[]); - i.bg(&bg); + i.bg(&bg, names); i.xm(&nums(&[40.0, 0.0, 0.0, -40.0, 10.0, 10.0])); i.bm(&nums(&[5.0, 0.0, 0.0, -40.0, 30.0, 10.0])); let Some(Paint::Gradient(gp)) = i.paint(&defs, Affine::IDENTITY) else { panic!() }; @@ -424,11 +455,12 @@ mod tests { #[test] fn gradient_stops_beyond_the_cap_are_ignored() { - let mut d = GradientDef::begin(&[V::Str(b"H".to_vec()), V::Num(0.0)]).unwrap(); + let names = &mut Names::default(); + let mut d = GradientDef::begin(&[V::Str(b"H".to_vec()), V::Num(0.0)], names).unwrap(); let mut spots = vec![]; for i in 0..257 { let ramp = i as f64 / 256.0 * 100.0; - d.stop(&nums(&[0.2, 0.4, 0.6, 0.0, 1.0, 1.0, 6.0, 50.0, ramp]), &mut spots); + d.stop(&nums(&[0.2, 0.4, 0.6, 0.0, 1.0, 1.0, 6.0, 50.0, ramp]), &mut spots, names); } let (_, g) = d.finish().unwrap(); assert_eq!(g.stops.len(), MAX_STOPS); @@ -438,16 +470,17 @@ mod tests { /// art space, `Xm` for the whole ramp and a `Bm` for each pair of stops. #[test] fn a_linear_gradient_runs_its_whole_ramp_not_its_last_part() { - let mut d = GradientDef::begin(&[V::Str(b"L".to_vec()), V::Num(0.0), V::Num(3.0)]).unwrap(); + let names = &mut Names::default(); + let mut d = GradientDef::begin(&[V::Str(b"L".to_vec()), V::Num(0.0), V::Num(3.0)], names).unwrap(); for ramp in [100.0, 60.0, 20.0] { - d.stop(&nums(&[0.2, 0.4, 0.6, 0.0, 1.0, 1.0, 6.0, 50.0, ramp]), &mut vec![]); + d.stop(&nums(&[0.2, 0.4, 0.6, 0.0, 1.0, 1.0, 6.0, 50.0, ramp]), &mut vec![], names); } let defs = HashMap::from([d.finish().unwrap()]); let instance = |xm: bool| { let mut i = Instance::begin(&[]); let mut bg = vec![V::Num(1.0), V::Str(b"L".to_vec())]; bg.extend(nums(&[0.3, -0.2, -90.0, 0.5, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0])); - i.bg(&bg); + i.bg(&bg, &mut Names::default()); if xm { i.xm(&nums(&[0.0, -200.0, -30.0, 0.0, 10.0, 300.0])); } diff --git a/crates/eps/src/import/ai/palette.rs b/crates/eps/src/import/ai/palette.rs index 43955d5fc..f96ae8bb5 100644 --- a/crates/eps/src/import/ai/palette.rs +++ b/crates/eps/src/import/ai/palette.rs @@ -18,7 +18,7 @@ use vectorcraft_color::Color; use super::lex::{Lexer, Tok}; -use super::obj::V; +use super::obj::{Names, V}; use super::paint::{self, Named}; /// Largest number of swatches read from one palette. @@ -60,8 +60,9 @@ impl Palette { } } -/// The palette of `data`: what follows `%AI5_BeginPalette`, up to its end. -pub(super) fn palette(data: &[u8]) -> Palette { +/// The palette of `data`: what follows `%AI5_BeginPalette`, up to its end, its names read by +/// `names`. +pub(super) fn palette(data: &[u8], names: &mut Names) -> Palette { let mut lex = Lexer::new(data); let mut out = Palette::default(); let mut vals: Vec = Vec::new(); @@ -110,29 +111,29 @@ pub(super) fn palette(data: &[u8]) -> Palette { cell = None; } "Bg" if in_gradient => { - if let Some(name) = last_text(&vals) { + if let Some(name) = last_text(&vals, names) { cell = Some(Cell::Gradient(name)); } } "BB" => in_gradient = false, - "p" => cell = first_text(&vals).map(Cell::Pattern), + "p" => cell = first_text(&vals, names).map(Cell::Pattern), "Xs" | "Xz" | "XS" | "XZ" => cell = None, "g" | "k" | "x" | "Xa" | "Xx" | "Xk" => { - cell = paint::color_op(w, &vals).and_then(|(p, named)| match named { + cell = paint::color_op_in(w, &vals, names).and_then(|(p, named)| match named { Some(Named { color, spot, .. }) => Some(Cell::Color { color, global: true, spot }), None => p.color().map(|color| Cell::Color { color, global: false, spot: false }), }); } "Pg" => { // Capped as swatches are: a file of many `Pg` would make as many empty groups. - if let Some(name) = first_text(&vals) + if let Some(name) = first_text(&vals, names) && out.groups.len() < MAX_SWATCHES { out.groups.push((name, Vec::new())); } } "Pc" => { - if let (Some(name), Some(c)) = (last_text(&vals), cell.take()) + if let (Some(name), Some(c)) = (last_text(&vals, names), cell.take()) && !name.starts_with('[') && out.len() < MAX_SWATCHES { @@ -157,12 +158,12 @@ fn push(vals: &mut Vec, v: V) { } } -fn first_text(vals: &[V]) -> Option { - vals.iter().find(|v| matches!(v, V::Str(_))).and_then(V::text) +fn first_text(vals: &[V], names: &mut Names) -> Option { + vals.iter().find(|v| matches!(v, V::Str(_))).and_then(|v| names.text(v)) } -fn last_text(vals: &[V]) -> Option { - vals.iter().rev().find(|v| matches!(v, V::Str(_))).and_then(V::text) +fn last_text(vals: &[V], names: &mut Names) -> Option { + vals.iter().rev().find(|v| matches!(v, V::Str(_))).and_then(|v| names.text(v)) } #[cfg(test)] @@ -173,6 +174,21 @@ mod tests { Entry { name: name.into(), cell: Cell::Color { color, global, spot } } } + fn palette(data: &[u8]) -> Palette { + super::palette(data, &mut Names::default()) + } + + /// Swatch, group and spot colour names that aren't UTF-8 are read in the encoding picked. + #[test] + fn palette_names_read_in_the_encoding() { + let mut names = Names::new(super::super::obj::NameEncoding::ShiftJis); + // 赤 (red) and 色 (color) in Shift_JIS. + let p = super::palette(b"0 0 Pb\n0 (\x90\x46) 1 Pg\n0 1 1 0 (\x90\xd4) 0 x\n(\x90\xd4)\nPc\nPB\n", &mut names); + assert_eq!(p.groups.first().map(|(n, s)| (n.as_str(), s.len())), Some(("色", 1))); + assert_eq!(p.groups.first().and_then(|(_, s)| s.first()), Some(&color("赤", Color::cmyk(0.0, 1.0, 1.0, 0.0), true, true))); + assert_eq!(names.notes().len(), 2); + } + #[test] fn a_palette_gives_its_swatches_in_order() { let p = palette( diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index cad61b744..7590e31a7 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -67,7 +67,7 @@ use vectorcraft_geom::{FillRule, PathData}; use super::super::ate::CANVAS_CENTRE; use super::lex::{Lexer, Tok}; -use super::obj::{self, Obj, V}; +use super::obj::{self, NameEncoding, Obj, V}; use super::paint::{self, GradientDef, Instance, Named}; use crate::ps; @@ -127,11 +127,20 @@ pub struct Structure { pub warnings: Vec, } +/// [`read_with`], names that aren't UTF-8 read as Windows-1252. +#[cfg(test)] +pub fn read(data: &[u8], large_canvas: bool) -> Result { + read_with(data, large_canvas, NameEncoding::default()) +} + /// Read the structure of the editing data `data`, applying the file's `%AI24_LargeCanvasScale` only /// when `large_canvas` is set: an EPS's page is drawn at the full size, but a `.ai`'s PDF part may -/// keep the reduced one, which the PDF import reads as it is (#2031). -pub fn read(data: &[u8], large_canvas: bool) -> Result { +/// keep the reduced one, which the PDF import reads as it is (#2031). Names that aren't UTF-8 +/// (layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches) are read in +/// `names`, each with a warning. +pub fn read_with(data: &[u8], large_canvas: bool, names: NameEncoding) -> Result { let mut r = Reader::new(data); + r.names = obj::Names::new(names); r.large_canvas = large_canvas.then_some(1.0); r.run()?; r.finish() @@ -272,6 +281,8 @@ struct Reader<'a> { /// The legacy text object being read (`To` … `TO`), and the modal type attributes. text: Option, type_state: legacy_text::TypeState, + /// How names that aren't UTF-8 are read, and the notes on them. + names: obj::Names, } /// A section comment's marker without its `AI_` prefix and value (`AI14_BeginSymbol` → @@ -364,6 +375,7 @@ impl<'a> Reader<'a> { pattern_tiles: HashMap::new(), text: None, type_state: legacy_text::TypeState::default(), + names: obj::Names::default(), } } diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index 1d5806a3c..d710863e6 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -63,12 +63,12 @@ impl<'a> Reader<'a> { } if m == "BeginArtStyles" { let section = self.section("EndArtStyles")?; - self.styles = crate::import::ai::style::art_styles(section); + self.styles = crate::import::ai::style::art_styles_in(section, &mut self.names); return Ok(()); } if m == "BeginPalette" { let section = self.section("EndPalette")?; - self.palette = crate::import::ai::palette::palette(section); + self.palette = crate::import::ai::palette::palette(section, &mut self.names); return Ok(()); } if m == "BeginEncoding" { @@ -198,6 +198,7 @@ impl<'a> Reader<'a> { return; } let mut sub = Reader::new(section); + sub.names = super::obj::Names::new(self.names.encoding); sub.subreader_depth = self.subreader_depth + 1; sub.pattern = Some(PatternHead::default()); // The gradients defined so far, lent (and those it defines kept). @@ -207,6 +208,7 @@ impl<'a> Reader<'a> { let read = sub.open(Kind::Group, false).and_then(|()| sub.run()); self.nodes = sub.nodes; self.gradients = std::mem::take(&mut sub.gradients); + self.names.absorb(std::mem::take(&mut sub.names)); // Unread art in the definition, hidden or not, leaves the tile incomplete. let whole = read.is_ok() && sub.unsupported.is_empty() @@ -248,6 +250,7 @@ impl<'a> Reader<'a> { _ => return, }; let mut sub = Reader::new(section); + sub.names = super::obj::Names::new(self.names.encoding); sub.subreader_depth = self.subreader_depth + 1; sub.cropmarks = self.cropmarks; sub.template = self.template; @@ -272,7 +275,8 @@ impl<'a> Reader<'a> { head.taken = true; let Some(at) = vals.iter().position(|v| matches!(v, V::Str(_))) else { return vals }; let tile = Self::nums(vals.get(at + 1..at + 5).unwrap_or_default()); - let ([x0, y0, x1, y1], Some(name)) = (tile.as_slice(), vals.get(at).and_then(V::text)) else { return vals }; + let [x0, y0, x1, y1] = tile.as_slice() else { return vals }; + let Some(name) = vals.get(at).and_then(|v| self.names.text(v)) else { return vals }; head.name = name; head.tile = Some(Rect::new(*x0, *y0, *x1, *y1)); vals.split_off(at + 5) diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index 0874ec095..c35c36819 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -5,6 +5,7 @@ use super::{ Affine, Arc, Artboard, CANVAS_CENTRE, ColorMode, Document, Node, NodeId, NodeKind, Obj, OpacityMask, Paint, Point, Reader, Rect, Structure, Swatch, Unit, V, obj, }; +use crate::import::ai::NameEncoding; use vectorcraft_doc::{Guide, ImageBlob, PatternDef}; /// `%AI5_RulerUnits` → [`Unit`] (Illustrator 7 spec table 3; centimeters is 4; pixels is 6 in later files). @@ -36,10 +37,11 @@ pub(crate) fn slot_frame(name: Option<&str>) -> usize { /// The artboards (names, and rects in art space) of the editing data `data` that [`read`] can't /// read as a whole, as far as it gets: where to put the pages of its PDF part instead, and what to -/// call them (#1068). -pub fn artboards(data: &[u8]) -> Vec<(String, Rect)> { - let found = artboards_read(data); - if !found.is_empty() { +/// call them (#1068). Names that aren't UTF-8 are read in `names`, and the notes on them are +/// returned too. +pub fn artboards(data: &[u8], names: NameEncoding) -> (Vec<(String, Rect)>, Vec) { + let found = artboards_read(data, names); + if !found.0.is_empty() { return found; } // Reading can stop before the artboards (art kept in the document's data comes first): read @@ -48,7 +50,7 @@ pub fn artboards(data: &[u8]) -> Vec<(String, Rect)> { let mut alone = b"%_/Document :\n%_/Dictionary :\n".to_vec(); alone.extend_from_slice(array); alone.extend_from_slice(b"\n%_; /NotRecorded ,\n%_;\n"); - artboards_read(&alone) + artboards_read(&alone, names) }) } @@ -84,14 +86,18 @@ pub struct PaletteSwatches { pub patterns: Vec, /// The image blobs the art of `patterns` shows, by key. pub images: std::collections::BTreeMap, + /// The notes on the names that weren't UTF-8. + pub notes: Vec, } /// The swatches and color groups of the palette of the editing data `data`, for a file whose /// layers aren't read: the swatches its PDF part has are the defaults (#4036). They come as /// [`read`] makes them, with pattern swatches only for the patterns in `patterns` and those the -/// editing data defines; `None` when the palette is empty or reading stops before it. -pub fn palette(data: &[u8], patterns: &[PatternDef]) -> Option { +/// editing data defines; `None` when the palette is empty or reading stops before it. Names that +/// aren't UTF-8 are read in `names`. +pub fn palette(data: &[u8], patterns: &[PatternDef], names: NameEncoding) -> Option { let mut r = Reader::new(data); + r.names = obj::Names::new(names); // The palette, the gradients and the patterns it names come before the art, so an error in the // art leaves them read. let _ = r.run(); @@ -111,13 +117,22 @@ pub fn palette(data: &[u8], patterns: &[PatternDef]) -> Option images.insert(im.key.clone(), blob.clone()); } }); - Some(PaletteSwatches { swatches, groups, patterns: added, images }) + let used: Vec<&str> = swatches + .iter() + .chain(groups.iter().flat_map(|g| &g.swatches)) + .map(|s| s.name.as_str()) + .chain(groups.iter().map(|g| g.name.as_str())) + .chain(added.iter().map(|p| p.name.as_str())) + .collect(); + let notes = r.names.notes_among(&used); + Some(PaletteSwatches { swatches, groups, patterns: added, images, notes }) } /// [`artboards`] as far as reading `data` from its start gets: those read before an error, and /// those of a `/Document` still open when it stopped (its art can be what stopped it). -fn artboards_read(data: &[u8]) -> Vec<(String, Rect)> { +fn artboards_read(data: &[u8], names: NameEncoding) -> (Vec<(String, Rect)>, Vec) { let mut r = Reader::new(data); + r.names = obj::Names::new(names); let _ = r.run(); let open: Vec = r.frames.iter().filter_map(|f| if let Kind::Obj(o) = &f.kind { Some((**o).clone()) } else { None }).collect(); for o in &open { @@ -125,7 +140,9 @@ fn artboards_read(data: &[u8]) -> Vec<(String, Rect)> { r.document(o); } } - r.artboards + let used: Vec<&str> = r.artboards.iter().map(|(n, _)| n.as_str()).collect(); + let notes = r.names.notes_among(&used); + (r.artboards, notes) } /// How many variables and data sets the variable library of the editing data has: XML nodes @@ -263,7 +280,7 @@ impl<'a> Reader<'a> { self.unreadable("opacity masks without readable art", false); } } - let name = match o.text("AIArtName") { + let name = match self.names.entry(o, "AIArtName") { Some(name) => name, None => match o.obj("AI10_ArtUID").and_then(Obj::content_text) { Some(id) => obj::xml_name(&id), @@ -292,7 +309,7 @@ impl<'a> Reader<'a> { if !sane(r) || r.area() <= 0.0 { continue; } - let name = ab.text("Name").unwrap_or_else(|| format!("Artboard {}", i + 1)); + let name = self.names.entry(ab, "Name").unwrap_or_else(|| format!("Artboard {}", i + 1)); self.artboards.push((name, r)); } } @@ -490,6 +507,7 @@ impl<'a> Reader<'a> { )); } warnings.extend(self.variables.note()); + warnings.extend(self.names.notes()); Ok(Structure { doc, to_doc, diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 7e7b79746..d7a5cfa4a 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -85,7 +85,7 @@ impl<'a> Reader<'a> { } if let Some(d) = self.gradient_def.as_mut() { match w { - "Bs" => d.stop(&vals, &mut self.stop_spots), + "Bs" => d.stop(&vals, &mut self.stop_spots, &mut self.names), "BD" => { if let Some((name, g)) = self.gradient_def.take().and_then(GradientDef::finish) { self.gradients.insert(name, g); @@ -133,7 +133,7 @@ impl<'a> Reader<'a> { "Lb" => self.layer_attrs(&vals), "Ln" => { if let Some(Frame { kind: Kind::Layer(a), .. }) = self.frames.last_mut() { - a.name = vals.iter().rev().find_map(V::text).unwrap_or_default(); + a.name = vals.iter().rev().find_map(|v| self.names.text(v)).unwrap_or_default(); } } "LB" => self.end_layer()?, @@ -216,12 +216,12 @@ impl<'a> Reader<'a> { self.style_marker(&vals, hidden) } // Gradients. - "Bd" => self.gradient_def = GradientDef::begin(&vals), + "Bd" => self.gradient_def = GradientDef::begin(&vals, &mut self.names), "Bs" | "BD" => {} "Bb" => self.instance = Some(Instance::begin(&vals)), "Bg" => { if let Some(i) = self.instance.as_mut() { - i.bg(&vals); + i.bg(&vals, &mut self.names); } } "Bm" => { @@ -344,7 +344,7 @@ impl<'a> Reader<'a> { if self.frames.is_empty() { return; } - let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None }); + let name = vals.iter().find(|v| matches!(v, V::Str(_))).and_then(|v| self.names.text(v)); let Some(tile) = name.as_deref().and_then(|n| self.pattern_tiles.get(n)).copied() else { self.unread_pattern(w); return; @@ -379,7 +379,7 @@ impl<'a> Reader<'a> { } fn color(&mut self, w: &str, vals: &[V]) { - let Some((p, named)) = paint::color_op(w, vals) else { return }; + let Some((p, named)) = paint::color_op_in(w, vals, &mut self.names) else { return }; // Registration (`Xs`, `Xz`) says nothing of the document's colour model. let registration = matches!(w, "Xs" | "XS" | "Xz" | "XZ"); match p.color().filter(|_| !registration) { @@ -499,7 +499,7 @@ impl<'a> Reader<'a> { /// follows an object. fn style_marker(&mut self, vals: &[V], hidden: bool) { let code = Self::nums(vals).last().copied().unwrap_or(0.0); - let style = vals.iter().rev().find_map(V::text).unwrap_or_default(); + let style = vals.iter().rev().find_map(|v| self.names.text(v)).unwrap_or_default(); let after = std::mem::take(&mut self.after_object); // An object's own state block ends `6 () XW` (a layer's `7 () XW`): an `Xy` before it isn't the layer's. if code == 6.0 { diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index ede1ae8e9..6aa2512f5 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -44,7 +44,7 @@ use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::{Dash, Effect, LineCap, LineJoin, WidthProfile}; use super::lex::{Lexer, Tok}; -use super::obj::V; +use super::obj::{Names, V}; use super::paint; const GLOW: &str = "Adobe Outer Glow"; // brand-ok: the filter's name in the files @@ -204,8 +204,15 @@ impl Default for Filter { /// Most filters read in one style. const MAX_FILTERS: usize = 4096; -/// The styles of the section `data` (between its markers), by name. +/// [`art_styles_in`], names read in the default encoding. +#[cfg(test)] pub(super) fn art_styles(data: &[u8]) -> HashMap { + art_styles_in(data, &mut Names::default()) +} + +/// The styles of the section `data` (between its markers), by name: the names of the styles and of +/// the colours, gradients and patterns they paint with read by `names`. +pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap { let mut styles = HashMap::new(); let mut name: Option = None; let mut filters: Vec = vec![]; @@ -239,7 +246,7 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap { open.clear(); (pending, in_basic) = (None, false); } - b"Name" if name.is_none() && filters.is_empty() => name = operands.iter().rev().find_map(V::text), + b"Name" if name.is_none() && filters.is_empty() => name = operands.iter().rev().find_map(|v| names.text(v)), b"CompoundFilter" => pending = Some(true), b"BasicFilter" => pending = Some(false), b"Part" => in_basic = false, @@ -305,7 +312,7 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap { open.pop(); } if let Some(f) = filters.last_mut() { - paint_op(f, op, &operands); + paint_op(f, op, &operands, names); } if op == "," || op == ";" || op.starts_with('X') || op.starts_with('B') || op.len() <= 2 { operands.clear(); @@ -322,12 +329,12 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap { styles } -/// A paint operator of a fill's or stroke's style. -fn paint_op(f: &mut Filter, op: &str, vals: &[V]) { +/// A paint operator of a fill's or stroke's style, the names it gives read by `names`. +fn paint_op(f: &mut Filter, op: &str, vals: &[V], names: &mut Names) { let last = vals.iter().rev().find_map(V::num); match op { "Xa" | "k" | "g" | "x" | "Xk" | "XA" | "K" | "G" | "X" | "XK" => { - let Some((p, _)) = paint::color_op(op, vals) else { return }; + let Some((p, _)) = paint::color_op_in(op, vals, names) else { return }; if op.ends_with(|c: char| c.is_ascii_lowercase()) { f.fill = Some(p); } else { @@ -381,13 +388,13 @@ fn paint_op(f: &mut Filter, op: &str, vals: &[V]) { "Bg" => { f.gradient = true; let at = vals.iter().rposition(|v| matches!(v, V::Str(_))); - let name = at.and_then(|i| vals.get(i)).and_then(V::text); + let name = at.and_then(|i| vals.get(i)).and_then(|v| names.text(v)); let n: Vec = at.map_or(&[][..], |i| vals.get(i + 1..).unwrap_or_default()).iter().filter_map(V::num).collect(); let angle = n.get(2).copied().filter(|a| a.is_finite()).unwrap_or(0.0); f.gradient_of = Some((name, angle)); } "p" => { - let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None }); + let name = vals.iter().find(|v| matches!(v, V::Str(_))).and_then(|v| names.text(v)); let matrix = vals.iter().rev().find_map(|v| match v { V::Arr(a) => { let n: Vec = a.iter().filter_map(V::num).collect(); diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index 964b74f10..607ea0eb1 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -1,5 +1,9 @@ use super::*; +fn paint_op(f: &mut Filter, op: &str, vals: &[V]) { + super::paint_op(f, op, vals, &mut Names::default()); +} + #[test] fn a_variable_width_stroke_keeps_its_profile() { let data = b"/KnownStyle :\n(Tapered) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index a0a24d865..7489ea725 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -1775,7 +1775,7 @@ fn artboards_after_what_stops_the_reading() { let array = format!("%_/Array :\n{}{}%_; (ArtboardArray) ,\n", board("one", 0), board("two", 200)); // Art in the document's own data that the reader can't follow comes before the artboards. let data = format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n%_/Dictionary :\nLB\n{array}%_; /NotRecorded ,\n%_;\n"); - let boards: Vec<_> = super::artboards(data.as_bytes()).into_iter().map(|(n, r)| (n, r.x0)).collect(); + let boards: Vec<_> = super::artboards(data.as_bytes(), NameEncoding::default()).0.into_iter().map(|(n, r)| (n, r.x0)).collect(); assert_eq!(boards, [("one".to_string(), 0.0), ("two".to_string(), 200.0)]); } @@ -3102,3 +3102,119 @@ fn an_objects_on_path_group_in_versioned_content_opens_once() { assert_eq!(kids[0].children().unwrap().len(), 5); assert!(kids[0].extras().objects_on_path.is_some()); } + +/// `body` read with its names that aren't UTF-8 in `encoding`. +fn read_names(body: &str, encoding: &str) -> Structure { + let encoding = NameEncoding::from_label(encoding).unwrap(); + match super::read_with(stream(body).as_bytes(), true, encoding) { + Ok(s) => s, + Err(e) => panic!("{e}"), + } +} + +/// The notes on names that weren't UTF-8. +fn name_notes(s: &Structure) -> Vec<&str> { + s.warnings.iter().map(String::as_str).filter(|w| w.contains("isn't UTF-8") || w.contains("aren't UTF-8")).collect() +} + +/// A layer's name in a legacy code page (`(name) Ln`, the bytes as octal escapes) is read in the +/// encoding picked, with a note that names it and the encoding; one in UTF-8 is read as it is in +/// every encoding, without a note. +#[test] +fn layer_names_are_read_in_the_legacy_encoding_picked() { + let layer_named = |name: &str| { + format!( + "%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{RED}{}f\nLB\n%AI5_EndLayer--\n", + rect_path(0.0, 0.0, 10.0, 10.0) + ) + }; + for (encoding, bytes, want) in [ + ("windows-949", r"\267\271\300\314\276\356 1", "레이어 1"), + ("shift_jis", r"\203\214\203C\203\204\201[ 1", "レイヤー 1"), + ("gbk", r"\315\274\262\343 1", "图层 1"), + ("big5", r"\271\317\274h 1", "圖層 1"), + ("macintosh", r"Caf\216", "Café"), + ("windows-1252", r"Caf\351", "Café"), + ] { + let s = read_names(&layer_named(bytes), encoding); + assert_eq!(s.doc.layers[0].name.as_deref(), Some(want), "{encoding}"); + assert_eq!( + name_notes(&s), + [format!("the name `{want}` isn't UTF-8 and was read as {encoding}: if it looks wrong, open the file with another legacyNameEncoding")], + "{encoding}" + ); + let utf8 = read_names(&layer_named(r"\353\240\210\354\235\264\354\226\264 1"), encoding); + assert_eq!(utf8.doc.layers[0].name.as_deref(), Some("레이어 1"), "{encoding}"); + assert!(name_notes(&utf8).is_empty(), "{encoding}: {:?}", utf8.warnings); + } + // By default the Korean bytes come in as Windows-1252, as before, now with the note. A comment + // in UTF-8 doesn't change how the names are read. + let s = read_names(&format!("%%Title: 레이어\n{}", layer_named(r"\267\271\300\314\276\356 1")), "windows-1252"); + assert_eq!(s.doc.layers[0].name.as_deref(), Some("·¹À̾î 1")); + assert_eq!( + name_notes(&s), + ["the name `·¹À̾î 1` isn't UTF-8 and was read as windows-1252: if it looks wrong, open the file with another legacyNameEncoding"] + ); + // Bytes the encoding doesn't define come in as U+FFFD, and the note says so. + let s = read_names(&layer_named(r"\267\271\300 1"), "windows-949"); + assert_eq!(s.doc.layers[0].name.as_deref(), Some("레\u{fffd} 1")); + assert_eq!( + name_notes(&s), + ["the name `레\u{fffd} 1` isn't UTF-8, and parts of it aren't valid windows-949 either: open the file with another legacyNameEncoding"] + ); +} + +/// An object's name, an artboard's, a spot colour's and a pattern's are read in the encoding +/// picked too, and a fill names the pattern as its definition does, whichever encoding is picked. +#[test] +fn object_artboard_colour_and_pattern_names_are_read_in_the_encoding_picked() { + // 그라 (a pattern and a group), 빨강 (an artboard and a spot colour), 레이어 1 (the layer). + let (pattern, red, layer_name) = (r"\261\327\266\363", r"\273\241\260\255", r"\267\271\300\314\276\356 1"); + let boards = format!( + "%_/Document :\n%_/Dictionary :\n%_/Array :\n\ + %_/Dictionary :\n%_0 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_200 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_({red}) /UnicodeString (Name) ,\n%_; ,\n\ + %_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n" + ); + let tile = DOTS.replace("(Dots)", &format!("({pattern})")); + let art_ = format!( + "0 Ae\nu\n0 1 1 0 ({red}) 0 x\n{}f\n({pattern}) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\nU\n%_/ArtDictionary :\n%_({pattern}) /String (AIArtName) ,\n%_;\n9 () XW\n", + rect_path(0.0, 0.0, 10.0, 10.0), + rect_path(20.0, 0.0, 30.0, 10.0), + ); + let body = format!("{boards}{tile}%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({layer_name}) Ln\n{art_}LB\n%AI5_EndLayer--\n"); + for (encoding, [pattern, red, layer_name]) in [("windows-949", ["그라", "빨강", "레이어 1"]), ("windows-1252", ["±×¶ó", "»¡°\u{ad}", "·¹À̾î 1"])] + { + let s = read_names(&body, encoding); + let doc = &s.doc; + assert_eq!(doc.layers[0].name.as_deref(), Some(layer_name), "{encoding}"); + assert_eq!(doc.artboards.first().map(|a| a.name.as_str()), Some(red), "{encoding}"); + let group = &art(doc)[0]; + assert_eq!(group.name.as_deref(), Some(pattern), "{encoding}"); + let kids = group.children().unwrap(); + assert!(matches!(fill(&kids[0]), Paint::Solid { swatch: Some(s), .. } if s == red), "{encoding}: {:?}", fill(&kids[0])); + assert!(doc.swatch(red).is_some_and(|s| s.spot), "{encoding}"); + assert!(matches!(fill(&kids[1]), Paint::Pattern { pattern: p, .. } if p == pattern), "{encoding}: {:?}", fill(&kids[1])); + assert!(doc.pattern(pattern).is_some(), "{encoding}"); + assert!(s.unread.is_empty(), "{encoding}: {:?}", s.unread); + assert_eq!(name_notes(&s).len(), 3, "{encoding}: {:?}", s.warnings); + } +} + +/// Past three names, the notes count the others in one. +#[test] +fn notes_on_names_that_arent_utf8_are_capped() { + let layers: String = (0..6) + .map(|i| { + format!( + "%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L{i} Caf\\216) Ln\n{RED}{}f\nLB\n%AI5_EndLayer--\n", + rect_path(0.0, 0.0, 10.0, 10.0) + ) + }) + .collect(); + let s = read_names(&layers, "macintosh"); + let names: Vec<_> = s.doc.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect(); + assert!(names.iter().all(|n| n.ends_with(" Café")), "{names:?}"); + let notes = name_notes(&s); + assert_eq!(notes.len(), 4, "{notes:?}"); + assert_eq!(notes[3], "more names that aren't UTF-8 were read as macintosh (3)"); +} diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 13a746b78..4d207a9e1 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -51,7 +51,7 @@ use build::{Frame, MADE, prune_unseen}; use compare::{ MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, PageView, Shares, compare, compared, comparison_render, comparison_samples, differ, differ_in_colour, }; -pub use layers::{ai_alone, layered_ai}; +pub use layers::{ai_alone, ai_alone_with, layered_ai, layered_ai_with}; pub(super) use layers::{layered, program_alone}; pub use slots::is_loss; #[cfg(test)] diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index bf0a8d4a5..9ae1e0b90 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -35,7 +35,7 @@ pub(super) struct Frame<'a> { /// (a `.ai` saved without its PDF part), its type is made from the file's text document alone and /// nothing is compared. `outlined`: the page's type was read as outlines, so it has none to put in /// the text objects. `unit`: the size in points of a unit of the page's user space (see -/// [`layered_ai`]). +/// [`layered_ai`]). `names`: the encoding of names that aren't UTF-8. pub(super) fn build( data: &[u8], visible: Option<&Document>, @@ -43,10 +43,11 @@ pub(super) fn build( outlined: bool, trust: bool, unit: f64, + names: ai::NameEncoding, ) -> Result<(Document, Vec), String> { // An EPS (its page is its art) draws a Large Canvas page at full size; a `.ai` keeps its art // space, as its PDF part may (#2031). - build_from(ai::read(data, matches!(page, Page::Art))?, data, visible, page, outlined, trust, unit) + build_from(ai::read_with(data, matches!(page, Page::Art), names)?, data, visible, page, outlined, trust, unit) } /// [`build`] from `structure`, the editing copy `data` already read. diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index 6be56c200..66a211b61 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -8,13 +8,14 @@ use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect /// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it /// has one that can't be used, `visible` with a warning that says why, with the swatches of the /// editing copy's palette ([`use_palette`]) when it could be read, and with its layer tree, empty, -/// below the page's layer when that could be read (#2033). -pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool) -> Imported { +/// below the page's layer when that could be read (#2033). `names`: the encoding of names that +/// aren't UTF-8. +pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, names: ai::NameEncoding) -> Imported { let Some(data) = editing_copy(ps) else { return visible }; // The layer tree is kept from the structure read, for the page to come in on when the layers // can't be used. let (tree, result) = match data.as_deref() { - Ok(d) => match ai::read(d, true) { + Ok(d) => match ai::read_with(d, true, names) { Ok(s) => (layer_tree(&s.doc.layers), build_from(s, d, Some(&visible.document), Page::Art, false, trust, 1.0)), Err(why) => (vec![], Err(why)), }, @@ -32,7 +33,8 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool) -> Im Err(why) => { let mut v = visible; if let Ok(data) = &data { - use_palette(data, &mut v.document); + let notes = use_palette(data, &mut v.document, names); + add_notes(&mut v.warnings, notes); } if tree.is_empty() { v.warnings.push(format!("{LAYERS_UNREAD} ({why}): it comes in as its page, in one layer")); @@ -87,8 +89,21 @@ fn layer_tree(nodes: &[Arc]) -> Vec> { /// `unit`: the size in points of a unit of the PDF part's user space, its first page's `UserUnit`, /// by which its art came in scaled. Editing data whose artboard matches the page only in those /// units comes in `unit` times as large, and so does the artboard layout `place_pages` takes from -/// it. +/// it. Names that aren't UTF-8 are read as Windows-1252 ([`layered_ai_with`]). pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outlined: bool, trust: bool, unit: f64) -> (Document, Vec, bool) { + layered_ai_with(private, visible, warnings, outlined, trust, unit, ai::NameEncoding::default()) +} + +/// [`layered_ai`], with the names that aren't UTF-8 read in `names`. +pub fn layered_ai_with( + private: &[u8], + visible: Document, + warnings: Vec, + outlined: bool, + trust: bool, + unit: f64, + names: ai::NameEncoding, +) -> (Document, Vec, bool) { let unread = |mut all: Vec, why: &str| { all.push(format!("{LAYERS_UNREAD} from its editing data ({why}): they come from its PDF part")); all @@ -99,19 +114,25 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outl }; if !has_layers(&data) { let mut visible = visible; - use_palette(&data, &mut visible); + let mut warnings = warnings; + add_notes(&mut warnings, use_palette(&data, &mut visible, names)); return (visible, warnings, false); } // The PDF part, its pages placed and named as the editing data's artboards are, when the // layers can't be used (`why`). let from_pdf_part = |mut visible: Document, warnings: Vec, why: &str| { - place_pages(&ai::artboards(&data), &mut visible, unit); - use_palette(&data, &mut visible); + let (boards, board_notes) = ai::artboards(&data, names); + let named = place_pages(&boards, &mut visible, unit); + let palette_notes = use_palette(&data, &mut visible, names); let mut all = unread(warnings, why); all.extend(ai::variables(&data).note()); + if named { + add_notes(&mut all, board_notes); + } + add_notes(&mut all, palette_notes); (visible, all, false) }; - match build(&data, Some(&visible), Page::Artboard, outlined, trust, unit) { + match build(&data, Some(&visible), Page::Artboard, outlined, trust, unit, names) { // An image too large for the editing data's reader would be missing from the layers, while // the PDF part has it: the file comes in as its PDF part instead (#1479). Ok((_, notes)) if notes.iter().any(|n| n == ai::IMAGE_TOO_LARGE) => { @@ -132,9 +153,10 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outl /// document read from the layers. The spot and pattern swatches of the PDF part's art that the /// palette doesn't have follow the palette's swatches. The patterns that the editing data defines and /// the PDF part lacks come in for the palette's pattern swatches, with the images they show. Left as -/// it is when the palette is empty. -fn use_palette(data: &[u8], doc: &mut Document) { - let Some(ai::PaletteSwatches { swatches, groups, patterns, images }) = ai::palette(data, &doc.patterns) else { return }; +/// it is when the palette is empty. `names`: the encoding of names that aren't UTF-8; the notes on +/// those the swatches take are returned. +fn use_palette(data: &[u8], doc: &mut Document, names: ai::NameEncoding) -> Vec { + let Some(ai::PaletteSwatches { swatches, groups, patterns, images, notes }) = ai::palette(data, &doc.patterns, names) else { return vec![] }; for (key, blob) in images { doc.images.entry(key).or_insert(blob); } @@ -148,6 +170,16 @@ fn use_palette(data: &[u8], doc: &mut Document) { let mut swatches = swatches; swatches.extend(art); (doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into()); + notes +} + +/// `notes` added to `warnings`, those it already has left out. +fn add_notes(warnings: &mut Vec, notes: Vec) { + for n in notes { + if !warnings.contains(&n) { + warnings.push(n); + } + } } /// The pages of `doc` (a `.ai` read as its PDF part: a page per artboard, in a row) named as @@ -155,10 +187,10 @@ fn use_palette(data: &[u8], doc: &mut Document) { /// each, to where they are, so the artboards keep their names and layout when the layers can't be /// read (#1068). Left as they are unless there are as many artboards as pages, each the size of its /// page in points, or, when `unit` is above 1, each the size of its page in units of `unit` points: -/// then the layout is drawn `unit` times as large. -fn place_pages(boards: &[(String, Rect)], doc: &mut Document, unit: f64) { +/// then the layout is drawn `unit` times as large. Whether the pages took the artboards' names. +fn place_pages(boards: &[(String, Rect)], doc: &mut Document, unit: f64) -> bool { if boards.is_empty() || boards.len() != doc.artboards.len() { - return; + return false; } let differ = |k: f64| { boards @@ -171,12 +203,12 @@ fn place_pages(boards: &[(String, Rect)], doc: &mut Document, unit: f64) { } else if unit.is_finite() && unit > 1.0 && !differ(unit) { unit } else { - return; + return false; }; for ((name, _), a) in boards.iter().zip(&mut doc.artboards) { a.name.clone_from(name); } - let [(_, first), _, ..] = boards else { return }; + let [(_, first), _, ..] = boards else { return true }; // Art space (y up) → the document, the first artboard's top left at the origin, as `read` has it, // `k` points a unit. let to_doc = Affine::new([k, 0.0, 0.0, -k, -first.x0 * k, first.y1 * k]); @@ -188,6 +220,7 @@ fn place_pages(boards: &[(String, Rect)], doc: &mut Document, unit: f64) { for (a, v) in doc.artboards.iter_mut().zip(&moves) { a.rect = a.rect + *v; } + true } /// The objects in `layer` (and its sublayers) moved by the move of the page they are on. @@ -211,9 +244,15 @@ fn place_art(layer: &mut Node, pages: &[Rect], moves: &[Vec2]) { /// A `.ai` saved without its PDF part (its pages show only a placeholder) from its editing copy /// alone (`private`, the joined `AIPrivateData` streams) → the document and the import's notes. +/// Names that aren't UTF-8 are read as Windows-1252 ([`ai_alone_with`]). pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec), String> { + ai_alone_with(private, ai::NameEncoding::default()) +} + +/// [`ai_alone`], with the names that aren't UTF-8 read in `names`. +pub fn ai_alone_with(private: &[u8], names: ai::NameEncoding) -> Result<(Document, Vec), String> { let data = ai::decode_private(private)?; - build(&data, None, Page::Artboard, false, false, 1.0) + build(&data, None, Page::Artboard, false, false, 1.0, names) } /// Does the editing data (or a program in the legacy format) have layers? @@ -236,15 +275,16 @@ fn editing_copy(ps: &[u8]) -> Option, String>> { /// but whose program has no layers (Illustrator 3 had none; image editors still export their /// paths so, storytold/photocraft#6036) is read with its art on one layer. `None` when the program /// has no layers and isn't in the format: other PostScript isn't read as the format's operators. -pub(in super::super) fn program_alone(ps: &[u8], illustrator: bool) -> Option), String>> { +/// `names`: the encoding of names that aren't UTF-8. +pub(in super::super) fn program_alone(ps: &[u8], illustrator: bool, names: ai::NameEncoding) -> Option), String>> { if ai::has_eps_data(ps) { return None; } if has_layers(ps) { - return Some(build(ps, None, Page::Art, false, false, 1.0)); + return Some(build(ps, None, Page::Art, false, false, 1.0, names)); } let program = one_layer(ps).filter(|_| illustrator)?; - Some(build(&program, None, Page::Art, false, false, 1.0)) + Some(build(&program, None, Page::Art, false, false, 1.0, names)) } /// `ps` with its art (from its `%%EndSetup` to its `%%PageTrailer`, else its `%%Trailer`, else its diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 94127e8cf..0fee624fa 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -1051,6 +1051,25 @@ fn an_ai_file_that_comes_in_as_its_pdf_part_keeps_the_swatches_of_its_palette() assert!(d.swatch("Red ink").is_some_and(|s| s.spot)); } +/// Such a file's palette names that aren't UTF-8 are read in the encoding picked, with the notes on +/// them; a layer name that isn't UTF-8, in layers that aren't used, gets none. +#[test] +fn an_ai_file_that_comes_in_as_its_pdf_part_notes_its_palette_names_in_a_legacy_encoding() { + let image = "%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceGray XN\n[ 1 0 0 1 0 100 ] 6000 6000 0 Xh\n\ + [ 1 0 0 1 0 100 ] 0 0 6000 6000 6000 6000 8 1 0 0 1 0 4 4 0 0\n%%BeginData: 1\rXI\n\u{0}%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN"; + // 빨강 (red) in code page 949, for the group and its ink; 그라 for the layer. + let palette = PALETTE.replace("Inks", r"\273\241\260\255").replace("Red ink", r"\273\241\260\255 ink"); + let art = native(&format!("{palette}{}", layer(r"\261\327\266\363", true, &format!("{}\n{image}", square(10, 10))))); + let (d, warnings, read) = crate::layered_ai_with(&private(&art), pdf_part(10.0, 86.0), vec![], false, true, 1.0, crate::NameEncoding::Korean); + assert!(!read, "the PDF part is used: {warnings:?}"); + let groups: Vec<&str> = d.swatch_groups.iter().map(|g| g.name.as_str()).collect(); + assert_eq!(groups, ["빨강"]); + assert!(d.swatch("빨강 ink").is_some_and(|s| s.spot)); + let notes: Vec<&String> = warnings.iter().filter(|w| w.contains("isn't UTF-8")).collect(); + assert_eq!(notes.len(), 2, "{warnings:?}"); + assert!(notes.iter().all(|n| n.contains("windows-949") && !n.contains("그라")), "{notes:?}"); +} + /// A `.ai` pattern library (a palette of pattern swatches and no layers) whose PDF part defines no /// pattern gets the patterns its editing data defines with the palette's pattern swatches. #[test] diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index 2b828e093..3f55ac220 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -37,7 +37,10 @@ mod ps; mod scene; mod tiff; -pub use import::{Imported, POSTSCRIPT_ERRORS, ai_alone, family_style, import, import_trusting, import_with, is_loss, layered_ai, page}; +pub use import::{ + Imported, NameEncoding, POSTSCRIPT_ERRORS, ai_alone, ai_alone_with, family_style, import, import_names, import_trusting, import_with, is_loss, + layered_ai, layered_ai_with, page, +}; pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai}; pub use print::{PrintJob, PrintPage, print}; diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f3f6cbfbb..85412e9b6 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4444,6 +4444,9 @@ axis Both И двете @msg its objects are nested too deeply обектите му са вложени твърде дълбоко @msg its section ending with `%{end}` doesn't end разделът му, завършващ на `%{end}`, няма край @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet обектите с множество запълвания или щрихове, ефекти или четки се появиха във вид на изрисувания си изглед ({_1}): тяхната поява все още не подлежи на редактиране +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding името `{name}` не е в UTF-8 и е прочетено като {label}: ако изглежда грешно, отворете файла с друго legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding името `{name}` не е в UTF-8, а части от него не са валидни и в {label}: отворете файла с друго legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) още имена, които не са в UTF-8, са прочетени като {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it градиентът „{name}“ не е дефиниран във файла: заменен е с обикновен цвят @msg an image VectorCraft can't read was left out изображение, което VectorCraft не може да разчете, беше пропуснато @msg an image whose data is cut short was left out изображение с непълни данни беше пропуснато diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index d7efa9c0e..36b4c7773 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4846,6 +4846,9 @@ imageTrace Outline Obris @msg its objects are nested too deeply njeni objekti su previše duboko ugniježđeni @msg its section ending with `%{end}` doesn't end njen odjeljak koji završava s `%{end}` ne završava @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekti s više ispuna ili poteza, efekata ili četkica stigli su kao njihov iscrtani izgled ({_1}): njihov izgled se još ne može uređivati +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding naziv `{name}` nije u UTF-8 i pročitan je kao {label}: ako izgleda pogrešno, otvorite datoteku s drugim legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding naziv `{name}` nije u UTF-8, a dijelovi nisu ispravni ni u {label}: otvorite datoteku s drugim legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) još naziva koji nisu u UTF-8 pročitano je kao {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it gradijent „{name}” nije definisan u datoteci: umjesto njega koristi se obična boja @msg an image VectorCraft can't read was left out izostavljena je slika koju VectorCraft ne može pročitati @msg an image whose data is cut short was left out izostavljena je slika čiji su podaci skraćeni diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index cd6172d6a..4b380f0d6 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5267,6 +5267,9 @@ digits Malayalam Malaiàlam @msg objects can't be nested more than 32 deep els objectes no es poden niar més de 32 nivells @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path els objectes dins d’una fusió, un embolcall, una repetició o un grup Intertwine no es poden adjuntar a un traçat @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet els objectes amb diversos farciments o traços, efectes o pinzells han arribat amb el seu aspecte dibuixat ({_1}): la seva aparença encara no és editable +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding el nom `{name}` no és UTF-8 i s’ha llegit com a {label}: si sembla incorrecte, obriu el fitxer amb un altre legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding el nom `{name}` no és UTF-8, i algunes parts tampoc no són {label} vàlid: obriu el fitxer amb un altre legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) s’han llegit com a {label} més noms que no són UTF-8 ({count}) @msg Offset Path: select paths Desplaça el traçat: selecciona traçats @msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Desplaça el traçat: el desplaçament no deixa res dels traçats seleccionats (una retracció més gran que les formes) @msg only a dock group above or below others collapses, not one beside another només es plega un grup d’acoblament situat a sobre o a sota d’altres, no un al costat d’un altre diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 34c7f4e03..bbad58f34 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4886,6 +4886,9 @@ imageTrace Outline Obrys @msg its objects are nested too deeply jeho objekty jsou vnořené příliš hluboko @msg its section ending with `%{end}` doesn't end jeho oddíl končící `%{end}` nemá konec @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekty s více výplněmi nebo tahy, efekty či štětci byly načteny jako vykreslený vzhled ({_1}): jejich vzhled zatím nelze upravovat +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding název `{name}` není v UTF-8 a byl načten jako {label}: pokud vypadá špatně, otevřete soubor s jiným legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding název `{name}` není v UTF-8 a jeho části nejsou platné ani v {label}: otevřete soubor s jiným legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) další názvy, které nejsou v UTF-8, byly načteny jako {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it přechod „{name}“ není v souboru definován: zastupuje ho plná barva @msg an image VectorCraft can't read was left out obrázek, který VectorCraft neumí přečíst, byl vynechán @msg an image whose data is cut short was left out obrázek s useknutými daty byl vynechán diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 5a2391342..83cf8b913 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4538,6 +4538,9 @@ imageTrace Outline Kontur @msg its objects are nested too deeply dens objekter er indlejret for dybt @msg its section ending with `%{end}` doesn't end dens sektion, der ender med `%{end}`, ender ikke @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekter med flere fyld eller streger, effekter eller pensler kom ind, som de er tegnet ({_1}): deres udseende er endnu ikke redigerbart +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding navnet `{name}` er ikke UTF-8 og blev læst som {label}: hvis det ser forkert ud, så åbn filen med en anden legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding navnet `{name}` er ikke UTF-8, og dele af det er heller ikke gyldig {label}: åbn filen med en anden legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) flere navne, der ikke er UTF-8, blev læst som {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it gradienten »{name}« er ikke defineret i filen: der bruges en flad farve i stedet @msg an image VectorCraft can't read was left out et billede, som VectorCraft ikke kan læse, blev udeladt @msg an image whose data is cut short was left out et billede, hvis data er afkortet, blev udeladt diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 101bbefac..6b710bbdb 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4495,6 +4495,9 @@ theme Appearance Erscheinungsbild @msg its objects are nested too deeply seine Objekte sind zu tief verschachtelt @msg its section ending with `%{end}` doesn't end sein Abschnitt, der mit `%{end}` enden soll, endet nicht @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet Objekte mit mehreren Flächen oder Konturen, Effekten oder Pinseln wurden so übernommen, wie sie gezeichnet aussehen ({_1}): Ihr Aussehen ist noch nicht bearbeitbar +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding Der Name `{name}` ist kein UTF-8 und wurde als {label} gelesen: Wenn er falsch aussieht, öffnen Sie die Datei mit einer anderen legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding Der Name `{name}` ist kein UTF-8, und Teile davon sind auch kein gültiges {label}: Öffnen Sie die Datei mit einer anderen legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) Weitere Namen, die kein UTF-8 sind, wurden als {label} gelesen ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it der Verlauf „{name}“ ist in der Datei nicht definiert: Eine einfache Farbe steht an seiner Stelle @msg an image VectorCraft can't read was left out ein Bild, das VectorCraft nicht lesen kann, wurde ausgelassen @msg an image whose data is cut short was left out ein Bild mit abgeschnittenen Daten wurde ausgelassen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e704faade..26ce306f8 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4528,6 +4528,9 @@ digits Malayalam Μαλαγιάλαμ @msg its objects are nested too deeply τα αντικείμενά του έχουν υπερβολικά βαθιά ένθεση @msg its section ending with `%{end}` doesn't end η ενότητά του που τελειώνει σε `%{end}` δεν τερματίζεται @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet τα αντικείμενα με πολλαπλά γεμίσματα, περιγράμματα, εφέ ή πινέλα εισήχθησαν ως απεικονίσεις ({_1}): η εμφάνισή τους δεν είναι ακόμη επεξεργάσιμη +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding το όνομα `{name}` δεν είναι UTF-8 και διαβάστηκε ως {label}: αν φαίνεται λάθος, ανοίξτε το αρχείο με άλλο legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding το όνομα `{name}` δεν είναι UTF-8 και τμήματά του δεν είναι έγκυρα ούτε σε {label}: ανοίξτε το αρχείο με άλλο legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) περισσότερα ονόματα που δεν είναι UTF-8 διαβάστηκαν ως {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it το ντεγκραντέ «{name}» δεν ορίζεται στο αρχείο: χρησιμοποιείται στη θέση του ένα συμπαγές χρώμα @msg an image VectorCraft can't read was left out παραλείφθηκε εικόνα που δεν μπορεί να διαβάσει το VectorCraft @msg an image whose data is cut short was left out παραλείφθηκε εικόνα με περικομμένα δεδομένα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4ebddbe9a..3e483ce9f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4580,6 +4580,9 @@ axis Both Ambos @msg its objects are nested too deeply sus objetos están anidados a demasiada profundidad @msg its section ending with `%{end}` doesn't end su sección que termina con `%{end}` no termina @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet los objetos con varios rellenos o trazos, efectos o pinceles se importaron con su aspecto dibujado ({_1}): su aspecto aún no se puede editar +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding el nombre `{name}` no está en UTF-8 y se leyó como {label}: si no se ve bien, abre el archivo con otro legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding el nombre `{name}` no está en UTF-8 y partes de él tampoco son {label} válido: abre el archivo con otro legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) se leyeron como {label} más nombres que no están en UTF-8 ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it el degradado “{name}” no está definido en el archivo: lo sustituye un color sólido @msg an image VectorCraft can't read was left out se omitió una imagen que VectorCraft no puede leer @msg an image whose data is cut short was left out se omitió una imagen con datos incompletos diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 7042b21f2..296ba6e25 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4788,6 +4788,9 @@ imageTrace Outline Ääriviiva @msg its objects are nested too deeply sen objektit ovat liian syvällä sisäkkäin @msg its section ending with `%{end}` doesn't end sen `%{end}`-merkintään päättyvä osio ei pääty @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objektit, joilla on useita täyttöjä tai viivoja, tehosteita tai siveltimiä, tuotiin piirretyn näköisinä ({_1}): niiden ulkoasua ei voi vielä muokata +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding nimi `{name}` ei ole UTF-8:aa, ja se luettiin koodauksella {label}: jos se näyttää väärältä, avaa tiedosto toisella legacyNameEncoding-arvolla +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding nimi `{name}` ei ole UTF-8:aa, eivätkä kaikki sen osat ole kelvollista {label}-koodausta: avaa tiedosto toisella legacyNameEncoding-arvolla +@msg more names that aren't UTF-8 were read as {label} ({count}) muita nimiä, jotka eivät ole UTF-8:aa, luettiin koodauksella {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it liukuväriä “{name}” ei ole määritetty tiedostossa: sen tilalla on tasaväri @msg an image VectorCraft can't read was left out kuva, jota VectorCraft ei voi lukea, jätettiin pois @msg an image whose data is cut short was left out kuva, jonka data on katkennut, jätettiin pois diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 48eb621eb..c70cfd228 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4570,6 +4570,9 @@ axis Both Les deux @msg its objects are nested too deeply leurs objets sont imbriqués trop profondément @msg its section ending with `%{end}` doesn't end leur section qui se termine par `%{end}` ne se termine pas @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet les objets à plusieurs fonds ou contours, effets ou formes ont été importés tels qu’ils sont dessinés ({_1}) : leur aspect n’est pas encore modifiable +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding le nom `{name}` n’est pas en UTF-8 et a été lu en {label} : s’il semble incorrect, ouvrez le fichier avec un autre legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding le nom `{name}` n’est pas en UTF-8, et certaines parties ne sont pas non plus du {label} valide : ouvrez le fichier avec un autre legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) d’autres noms qui ne sont pas en UTF-8 ont été lus en {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it le dégradé « {name} » n’est pas défini dans le fichier : une couleur unie le remplace @msg an image VectorCraft can't read was left out une image que VectorCraft ne peut pas lire a été ignorée @msg an image whose data is cut short was left out une image aux données tronquées a été ignorée diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 81f948263..b62e5755f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4973,6 +4973,9 @@ imageTrace Outline Körvonal @msg object {id} is not a path az objektum nem útvonal: {id} @msg object {id} is not a selected top-level object az objektum nem kijelölt legfelső szintű objektum: {id} @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet a több kitöltésű vagy körvonalú, effektusos vagy ecsetes objektumok a megrajzolt kinézetükkel kerültek be ({_1}): a megjelenésük még nem szerkeszthető +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding a(z) `{name}` név nem UTF-8, ezért {label} kódolással lett beolvasva: ha hibásnak tűnik, nyissa meg a fájlt másik legacyNameEncoding értékkel +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding a(z) `{name}` név nem UTF-8, és egyes részei {label} kódolásban sem érvényesek: nyissa meg a fájlt másik legacyNameEncoding értékkel +@msg more names that aren't UTF-8 were read as {label} ({count}) további nem UTF-8 nevek lettek {label} kódolással beolvasva ({count}) @msg Offset Path: select paths Útvonal eltolása: jelölj ki útvonalakat @msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Útvonal eltolása: az eltolás után semmi sem marad a kijelölt útvonalakból (a befelé eltolás nagyobb az alakzatoknál) @msg onto must name a floating panel az onto egy lebegő panelt kell megnevezzen diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 89633ae99..157abb7b0 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4538,6 +4538,9 @@ imageTrace Outline Kontur @msg its objects are nested too deeply objeknya bersarang terlalu dalam @msg its section ending with `%{end}` doesn't end bagian yang berakhir dengan `%{end}` tidak berakhir @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objek dengan beberapa isian atau goresan, efek, atau kuas masuk sebagai tampilan gambarnya ({_1}): penampilannya belum dapat diedit +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding nama `{name}` bukan UTF-8 dan dibaca sebagai {label}: jika terlihat salah, buka file dengan legacyNameEncoding lain +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding nama `{name}` bukan UTF-8, dan sebagiannya juga bukan {label} yang valid: buka file dengan legacyNameEncoding lain +@msg more names that aren't UTF-8 were read as {label} ({count}) nama lain yang bukan UTF-8 dibaca sebagai {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it gradien “{name}” tidak didefinisikan dalam berkas: warna polos dipakai sebagai pengganti @msg an image VectorCraft can't read was left out gambar yang tidak dapat dibaca VectorCraft tidak disertakan @msg an image whose data is cut short was left out gambar yang datanya terpotong tidak disertakan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6acfaf3c9..213570c25 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4621,6 +4621,9 @@ axis Both Entrambi @msg its objects are nested too deeply i loro oggetti sono annidati troppo in profondità @msg its section ending with `%{end}` doesn't end la loro sezione che termina con `%{end}` non termina @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet gli oggetti con più riempimenti o tratti, effetti o pennelli sono stati importati come appaiono disegnati ({_1}): il loro aspetto non è ancora modificabile +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding il nome `{name}` non è in UTF-8 ed è stato letto come {label}: se sembra errato, apri il file con un altro legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding il nome `{name}` non è in UTF-8 e alcune sue parti non sono valide nemmeno in {label}: apri il file con un altro legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) altri nomi non in UTF-8 sono stati letti come {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it la sfumatura “{name}” non è definita nel file: la sostituisce un colore pieno @msg an image VectorCraft can't read was left out un'immagine che VectorCraft non può leggere è stata omessa @msg an image whose data is cut short was left out un'immagine con dati incompleti è stata omessa diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4bf3cd6bd..9c66309b1 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4328,6 +4328,9 @@ swatch Bright Violet ビビッドバイオレット @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise 表示に使われるフォントの 1 行に対して低すぎるフレーム内のエリア内文字は、フレームを高くしました ({_1}): そうしないと文字が表示されません @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it グラデーション「{name}」はファイル内で定義されていません: 代わりに単色を使用しています @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 塗りや線が複数ある、または効果やブラシを適用したオブジェクト {_1} 個は見た目のまま読み込まれました: アピアランスはまだ編集できません +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding 名前 `{name}` は UTF-8 ではないため {label} として読み込みました: 正しくない場合は別の legacyNameEncoding でファイルを開いてください +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding 名前 `{name}` は UTF-8 ではなく、一部は {label} としても無効です: 別の legacyNameEncoding でファイルを開いてください +@msg more names that aren't UTF-8 were read as {label} ({count}) UTF-8 ではないほかの名前を {label} として読み込みました ({count}) @msg patterns nested too deeply are filled with mid-grey 入れ子が深すぎるパターンは中間のグレーで塗りつぶされています @msg shadings of an unknown type were left out 種類が不明なシェーディングは除外されました @msg shadings whose functions are of an unknown type are filled with their middle colour 関数の種類が不明なシェーディングは中間の色で塗りつぶされています diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index cf411b072..72f7a3068 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4325,6 +4325,9 @@ font-technology Variable ცვლადი @msg its objects are nested too deeply მისი ობიექტები ზედმეტად ღრმადაა ჩადგმული @msg its section ending with `%{end}` doesn't end მისი სექცია, რომელიც `%{end}`-ით მთავრდება, არ სრულდება @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet რამდენიმე შევსების ან კონტურის, ეფექტის ან ფუნჯის მქონე ობიექტები შემოვიდა დახატული სახით ({_1}): მათი იერსახე ჯერ არ რედაქტირდება +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding სახელი `{name}` არ არის UTF-8 და წაკითხულია როგორც {label}: თუ არასწორად გამოიყურება, გახსენით ფაილი სხვა legacyNameEncoding-ით +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding სახელი `{name}` არ არის UTF-8 და მისი ნაწილები არც {label}-ში არის სწორი: გახსენით ფაილი სხვა legacyNameEncoding-ით +@msg more names that aren't UTF-8 were read as {label} ({count}) სხვა სახელები, რომლებიც არ არის UTF-8, წაკითხულია როგორც {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it გრადიენტი „{name}“ ფაილში განსაზღვრული არ არის: მის ნაცვლად ერთგვაროვანი ფერია @msg an image VectorCraft can't read was left out გამოსახულება, რომელსაც VectorCraft ვერ კითხულობს, გამოტოვებულია @msg an image whose data is cut short was left out გამოსახულება, რომლის მონაცემებიც შეკვეცილია, გამოტოვებულია diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 81f6c600c..d10c6611f 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4255,6 +4255,9 @@ theme Appearance 외관 @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise 표시되는 글꼴의 한 줄보다 낮은 프레임의 영역 문자는 프레임을 더 높였습니다({_1}). 그렇지 않으면 문자가 표시되지 않습니다 @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it 그레이디언트 “{name}”이(가) 파일에 정의되어 있지 않아 단색으로 대체했습니다 @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 칠이나 선이 여러 개인 개체, 효과, 브러시가 있는 개체는 그려진 모양({_1})으로 가져왔습니다: 모양새는 아직 편집할 수 없습니다 +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding 이름 `{name}`은(는) UTF-8이 아니어서 {label}(으)로 읽었습니다: 잘못 보이면 다른 legacyNameEncoding으로 파일을 여세요 +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding 이름 `{name}`은(는) UTF-8이 아니고 일부는 {label}에서도 올바르지 않습니다: 다른 legacyNameEncoding으로 파일을 여세요 +@msg more names that aren't UTF-8 were read as {label} ({count}) UTF-8이 아닌 다른 이름도 {label}(으)로 읽었습니다({count}) @msg patterns nested too deeply are filled with mid-grey 너무 깊게 중첩된 패턴은 중간 회색으로 채웁니다 @msg shadings of an unknown type were left out 알 수 없는 종류의 셰이딩은 제외했습니다 @msg shadings whose functions are of an unknown type are filled with their middle colour 함수 종류를 알 수 없는 셰이딩은 중간 색상으로 채웁니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 344853683..53bcb2100 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4550,6 +4550,9 @@ imageTrace Outline Contour @msg its objects are nested too deeply de objecten ervan zijn te diep genest @msg its section ending with `%{end}` doesn't end zijn sectie die op `%{end}` eindigt, eindigt niet @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objecten met meerdere vullingen of lijnen, effecten of penselen zijn als hun getekende uiterlijk binnengekomen ({_1}): hun vormgeving is nog niet bewerkbaar +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding de naam `{name}` is geen UTF-8 en is gelezen als {label}: als die er verkeerd uitziet, open het bestand dan met een andere legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding de naam `{name}` is geen UTF-8, en delen ervan zijn ook geen geldige {label}: open het bestand met een andere legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) meer namen die geen UTF-8 zijn, zijn gelezen als {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it verloop „{name}” is niet gedefinieerd in het bestand: er is een effen kleur voor gebruikt @msg an image VectorCraft can't read was left out een afbeelding die VectorCraft niet kan lezen, is weggelaten @msg an image whose data is cut short was left out een afbeelding waarvan de gegevens zijn afgekapt, is weggelaten diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 14825f83c..d32a38eb9 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4712,6 +4712,9 @@ imageTrace Outline Kontur @msg its objects are nested too deeply jego obiekty są zagnieżdżone zbyt głęboko @msg its section ending with `%{end}` doesn't end jego sekcja kończąca się na `%{end}` nie ma końca @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet obiekty z kilkoma wypełnieniami lub obrysami, efektami albo pędzlami wczytano jako ich narysowany wygląd ({_1}): ich wyglądu nie można jeszcze edytować +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding nazwa `{name}` nie jest w UTF-8 i została odczytana jako {label}: jeśli wygląda źle, otwórz plik z innym legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding nazwa `{name}` nie jest w UTF-8, a jej części nie są poprawne również w {label}: otwórz plik z innym legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) kolejne nazwy spoza UTF-8 odczytano jako {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it gradient „{name}” nie jest zdefiniowany w pliku: zastąpiono go jednolitym kolorem @msg an image VectorCraft can't read was left out pominięto obraz, którego VectorCraft nie potrafi odczytać @msg an image whose data is cut short was left out pominięto obraz z uciętymi danymi diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 96a31c3e5..895100d1b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4151,6 +4151,9 @@ axis Both Ambos @msg its objects are nested too deeply seus objetos estão aninhados com profundidade excessiva @msg its section ending with `%{end}` doesn't end sua seção que termina com `%{end}` não termina @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objetos com vários preenchimentos ou traços, efeitos ou pincéis entraram com a aparência desenhada ({_1}): sua aparência ainda não é editável +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding o nome `{name}` não está em UTF-8 e foi lido como {label}: se parecer errado, abra o arquivo com outro legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding o nome `{name}` não está em UTF-8, e partes dele também não são {label} válido: abra o arquivo com outro legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) mais nomes que não estão em UTF-8 foram lidos como {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it o gradiente “{name}” não está definido no arquivo: uma cor sólida o substitui @msg an image VectorCraft can't read was left out uma imagem que o VectorCraft não consegue ler foi omitida @msg an image whose data is cut short was left out uma imagem com dados incompletos foi omitida diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index fdb51b80d..d168bba96 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4616,6 +4616,9 @@ axis Both Оба @msg its objects are nested too deeply объекты в них вложены слишком глубоко @msg its section ending with `%{end}` doesn't end раздел, заканчивающийся на `%{end}`, не заканчивается @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet объекты с несколькими заливками или обводками, эффектами или кистями импортированы в нарисованном виде ({_1}): их оформление пока нельзя редактировать +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding имя `{name}` не в UTF-8 и прочитано как {label}: если оно выглядит неверно, откройте файл с другим legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding имя `{name}` не в UTF-8, и его части недопустимы и в {label}: откройте файл с другим legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) другие имена не в UTF-8 прочитаны как {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it градиент «{name}» не определён в файле: вместо него используется сплошной цвет @msg an image VectorCraft can't read was left out изображение, которое VectorCraft не может прочитать, пропущено @msg an image whose data is cut short was left out изображение с обрезанными данными пропущено diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index e2dddf022..00561a5f7 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4479,6 +4479,9 @@ axis Both Båda @msg its objects are nested too deeply dess objekt är för djupt kapslade @msg its section ending with `%{end}` doesn't end dess avsnitt som slutar med `%{end}` avslutas aldrig @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekt med flera fyllningar eller konturer, effekter eller penslar importerades som sitt ritade utseende ({_1}): deras utseende går inte att redigera än +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding namnet `{name}` är inte UTF-8 och lästes som {label}: om det ser fel ut, öppna filen med en annan legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding namnet `{name}` är inte UTF-8, och delar av det är inte heller giltig {label}: öppna filen med en annan legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) fler namn som inte är UTF-8 lästes som {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it gradienten ”{name}” är inte definierad i filen: en enfärgad fyllning används i stället @msg an image VectorCraft can't read was left out en bild som VectorCraft inte kan läsa utelämnades @msg an image whose data is cut short was left out en bild med avkortade data utelämnades diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 7e5ca938c..25fe28806 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1736,6 +1736,28 @@ fn saved_tracing_preset_errors_are_translated() { } } +/// The notes on names in a `.ai`'s editing data that aren't UTF-8 are translated in every language +/// that translates every message, keeping the name, the encoding and the param. +#[test] +fn legacy_name_notes_are_translated() { + let notes = [ + "the name `·¹À̾î 1` isn't UTF-8 and was read as windows-1252: if it looks wrong, open the file with another legacyNameEncoding", + "the name `레\u{fffd} 1` isn't UTF-8, and parts of it aren't valid windows-949 either: open the file with another legacyNameEncoding", + "more names that aren't UTF-8 were read as macintosh (7)", + ]; + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + for note in notes { + let shown = message(lang, note); + assert_ne!(shown, note, "{code}"); + assert!(shown.contains("UTF-8"), "{code}: {shown}"); + } + let shown = message(lang, notes[0]); + assert!(shown.contains("`·¹À̾î 1`") && shown.contains("windows-1252") && shown.contains("legacyNameEncoding"), "{code}: {shown}"); + assert!(message(lang, notes[2]).contains("macintosh") && message(lang, notes[2]).contains('7'), "{code}"); + } +} + /// The status bar's line for an EPS file whose PostScript stops on an error, opened partly or /// refused, and the refusal's dialog keep the error's name as PostScript spells it and its meaning, /// which each language shows as its catalog translates the meaning. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 9ecf2ccf9..966c7652c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4322,6 +4322,9 @@ axis Both Her İkisi @msg its objects are nested too deeply nesneleri çok derin bir şekilde iç içe geçmiş @msg its section ending with `%{end}` doesn't end `%{end}` ile biten bölümü bitmiyor @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet çeşitli dolgulara veya konturlara, efektlere veya fırçalara sahip nesneler çizilmiş görünümleri olarak geldi ({_1}): görünümleri henüz düzenlenemez +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding `{name}` adı UTF-8 değil ve {label} olarak okundu: yanlış görünüyorsa dosyayı başka bir legacyNameEncoding ile açın +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding `{name}` adı UTF-8 değil ve bazı bölümleri {label} olarak da geçerli değil: dosyayı başka bir legacyNameEncoding ile açın +@msg more names that aren't UTF-8 were read as {label} ({count}) UTF-8 olmayan diğer adlar {label} olarak okundu ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it “{name}” degradesi dosyada tanımlı değil: onun yerine düz bir renk geliyor @msg an image VectorCraft can't read was left out VectorCraft'ın okuyamadığı bir resim dışarıda bırakıldı @msg an image whose data is cut short was left out verileri kısa kesilen bir resim dışarıda bırakıldı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d348440a2..7bfe37d28 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4605,6 +4605,9 @@ axis Both Обидві @msg its objects are nested too deeply об’єкти в них вкладені надто глибоко @msg its section ending with `%{end}` doesn't end розділ, що закінчується на `%{end}`, не закінчується @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet об’єкти з кількома заливками або обведеннями, ефектами або пензлями імпортовані в намальованому вигляді ({_1}): їх оформлення поки що не можна редагувати +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding назва `{name}` не в UTF-8 і прочитана як {label}: якщо вона виглядає неправильно, відкрийте файл з іншим legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding назва `{name}` не в UTF-8, і її частини недійсні також у {label}: відкрийте файл з іншим legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) інші назви не в UTF-8 прочитано як {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it градієнт «{name}» не визначено у файлі: замість нього використовується суцільний колір @msg an image VectorCraft can't read was left out зображення, яке VectorCraft не може прочитати, пропущено @msg an image whose data is cut short was left out зображення з обрізаними даними пропущено diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 09652ede2..b4f9d3690 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4627,6 +4627,9 @@ theme Appearance Giao diện @msg object {id} is not a path đối tượng {id} không phải là đường dẫn @msg object {id} is not a selected top-level object đối tượng {id} không phải là đối tượng cấp cao nhất đã chọn @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet đối tượng có nhiều tô hoặc nét, hiệu ứng hoặc cọ đã vào dưới dạng bề ngoài ({_1}): diện mạo chưa thể chỉnh sửa +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding tên `{name}` không phải UTF-8 và đã được đọc theo {label}: nếu trông sai, hãy mở tệp với legacyNameEncoding khác +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding tên `{name}` không phải UTF-8, và một số phần cũng không hợp lệ trong {label}: hãy mở tệp với legacyNameEncoding khác +@msg more names that aren't UTF-8 were read as {label} ({count}) thêm các tên không phải UTF-8 đã được đọc theo {label} ({count}) @msg Offset Path: select paths Đường dẫn độ lệch: hãy chọn đường dẫn @msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Đường dẫn độ lệch: độ lệch không để lại gì từ các đường dẫn đã chọn (độ lệch vào trong lớn hơn hình) @msg onto must name a floating panel onto phải đặt tên bảng đang thả nổi diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 841bd4528..863aefcab 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4051,6 +4051,9 @@ imageTrace Outline 轮廓 @msg an image whose data is cut short was left out 已略去数据不完整的图像 @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it 文件中未定义渐变“{name}”:已改用纯色代替 @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 带有多个填色或描边、效果或画笔的对象已按其绘制外观导入 ({_1}):其外观尚不可编辑 +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding 名称 `{name}` 不是 UTF-8,已按 {label} 读取:如果显示不正确,请使用其他 legacyNameEncoding 打开文件 +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding 名称 `{name}` 不是 UTF-8,且部分内容在 {label} 中也无效:请使用其他 legacyNameEncoding 打开文件 +@msg more names that aren't UTF-8 were read as {label} ({count}) 其他非 UTF-8 名称已按 {label} 读取 ({count}) @msg patterns nested too deeply are filled with mid-grey 嵌套过深的图案以中灰色填充 @msg shadings of an unknown type were left out 已略去未知类型的着色 @msg shadings whose functions are of an unknown type are filled with their middle colour 函数类型未知的着色以其中间颜色填充 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a515d59a7..5bdc3a1a7 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1974,6 +1974,9 @@ file-filter Templates 範本 @msg an image whose data is cut short was left out 已略過資料不完整的影像 @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it 檔案中未定義漸層「{name}」:已改用單色替代 @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 具有多個填色或筆畫、效果或筆刷的物件已依其繪製外觀匯入 ({_1}):其外觀目前尚無法編輯 +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding 名稱 `{name}` 不是 UTF-8,已依 {label} 讀取:若顯示不正確,請以其他 legacyNameEncoding 開啟檔案 +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding 名稱 `{name}` 不是 UTF-8,且部分內容在 {label} 中也無效:請以其他 legacyNameEncoding 開啟檔案 +@msg more names that aren't UTF-8 were read as {label} ({count}) 其他非 UTF-8 名稱已依 {label} 讀取 ({count}) @msg patterns nested too deeply are filled with mid-grey 巢狀層級過深的圖樣以中灰色填滿 @msg shadings of an unknown type were left out 已略過類型未知的著色 @msg shadings whose functions are of an unknown type are filled with their middle colour 函數類型未知的著色以其中間顏色填滿 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index de7e1cfce..ae8ab3341 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -84,7 +84,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); the page's drawing of it (often in pieces) is left out. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches) are read as Windows-1252 unless `legacyNameEncoding` (`convert --legacy-name-encoding`) picks `macintosh`, `windows-949`, `shift_jis`, `gbk` or `big5`; the open result's warnings list the first three such names as read and count the rest. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); the page's drawing of it (often in pieces) is left out. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. ## Format by format From aecff872a52eb485abca7970fd5a7f9b25b6c5ee Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 18:50:43 -0800 Subject: [PATCH 11/71] .ai import: a variable-width stroke whose look draws nothing comes in as itself An object with a variable-width stroke whose drawn look holds nothing that draws, apart from copies read from %_ lines, has no outline to keep. It now comes in as the object with its width profile instead of being dropped. --- crates/eps/src/import/ai/read/ops.rs | 33 +++++++++++++++++++++++++++- crates/eps/src/import/ai/style.rs | 13 +++++++---- crates/eps/src/import/ai/tests.rs | 18 +++++++++++++++ 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 7e7b79746..08a71b853 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -531,6 +531,24 @@ impl<'a> Reader<'a> { { return; } + // A variable-width stroke's look that draws nothing but copies read from `%_` lines: an + // empty group, which [`styled`] takes the stroke for. + let drawn; + let look = if !hidden + && slot_of(object.name.as_deref()).is_none() + && matches!(look.kind, NodeKind::Group { .. }) + && self.styles.get(&style).is_some_and(|st| st.uneven) + && !draws(look, &self.hidden_ids) + { + let mut copy = look.clone(); + if let Some(children) = copy.children_mut() { + children.clear(); + } + drawn = copy; + &drawn + } else { + look + }; // A style read here: the object, painted as it says. if let Some(appearance) = self .styles @@ -658,7 +676,8 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p // A text object's slot (an empty group standing for it) takes the style as the type will. let slot = slot_of(object.name.as_deref()).is_some(); if !st.plain - || (!slot && !st.simple) + // A look with no outline in it to keep takes a variable-width stroke as the object's own. + || (!slot && !st.simple && (!st.uneven || draws(look, &HashSet::new()))) || (object.is_container() && !slot && !matches!(object.kind, NodeKind::Compound { .. }) && !st.items.is_empty()) { return None; @@ -733,3 +752,15 @@ fn strip(n: &mut Node, hidden: &HashSet) { } } } + +/// Whether `n` holds anything that draws, apart from the children read from `%_` lines (`hidden`): +/// a node other than a group or a compound path. +fn draws(n: &Node, hidden: &HashSet) -> bool { + if hidden.contains(&n.id) { + return false; + } + match n.children() { + Some(children) if matches!(n.kind, NodeKind::Group { .. } | NodeKind::Compound { .. }) => children.iter().any(|c| draws(c, hidden)), + _ => true, + } +} diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 6dda903c1..7b6c5facc 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -98,6 +98,9 @@ pub(super) struct ArtStyle { /// Shadow on the object: what any object is read with. Type takes the other plain styles too (its drawn look /// can't be retyped); other objects keep their drawn look, which is the app's own rendering. pub simple: bool, + /// It would be [`Self::simple`] but for a variable-width stroke: an object whose drawn look + /// is empty takes it with its profile. + pub uneven: bool, /// Its fills and strokes, bottom first. pub items: Vec, /// Each item's own visibility, transparency and effects, in the order of [`Self::items`]. @@ -652,14 +655,16 @@ fn style(filters: &[Filter]) -> ArtStyle { } st.plain = b.plain; let count = |stroke: bool| st.items.iter().filter(|i| matches!(i, StyleItem::Stroke { .. }) == stroke).count(); - st.simple = st.plain + let simple = st.plain && count(true) <= 1 && count(false) <= 1 - // A variable-width stroke curves between its width points, and `WidthProfile` is straight - // between them: the drawn look holds the outline as drawn. - && st.items.iter().all(|i| matches!(i, StyleItem::Fill(_) | StyleItem::Stroke { profile: None, .. } | StyleItem::Gradient { .. })) + && st.items.iter().all(|i| matches!(i, StyleItem::Fill(_) | StyleItem::Stroke { .. } | StyleItem::Gradient { .. })) && st.looks.iter().all(|l| l.visible && l.opacity >= 1.0 && l.blend == BlendMode::Normal && l.effects.is_empty()) && st.effects.iter().all(|e| matches!(e.id.as_str(), "stylize.outerGlow" | "stylize.feather" | "stylize.dropShadow")); + // A variable-width stroke curves between its width points, and `WidthProfile` is straight + // between them: the drawn look holds the outline as drawn. + let even = st.items.iter().all(|i| !matches!(i, StyleItem::Stroke { profile: Some(_), .. })); + (st.simple, st.uneven) = (simple && even, simple && !even); st } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index fff315126..c4786fa0d 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -1916,6 +1916,24 @@ fn a_variable_width_stroke_comes_in_as_its_drawn_outline() { assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); } +#[test] +fn a_variable_width_stroke_with_an_empty_look_comes_in_as_itself() { + // GUARDS: with no outline in its look to keep, the object was dropped. + let hidden_line = "0 5 m\n10 5 L\n".replace('\n', "\n%_"); + let hidden_fill = format!("{}f\n", rect_path(0.0, 3.0, 10.0, 7.0)).replace('\n', "\n%_"); + let lens = width_profile_style(&[(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)]); + // An empty look, and ones whose group or compound path holds only a copy read from `%_` lines. + for look in ["u\nU\n".to_string(), format!("u\nu\n%_{hidden_fill}\nU\nU\n"), format!("u\n*u\n%_{hidden_fill}\n*U\nU\n")] { + let art_ = format!("{look}%_{hidden_line}S\n1 (Tapered) XW\n"); + let r = read_ok(&format!("{lens}{}", layer(&art_))); + let a = art(&r.doc); + let [object] = a.as_slice() else { panic!("{look:?}: {a:#?}") }; + assert!(matches!(object.kind, NodeKind::Path { .. }), "{look:?}: {object:#?}"); + assert_eq!(object.appearance.stroke().map(|s| (s.width, s.profile.is_some())), Some((4.0, true)), "{look:?}: {object:#?}"); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{look:?}: {:?}", r.warnings); + } +} + #[test] fn a_bare_comment_line_in_a_prolog_leaves_its_end_alone() { // GUARDS: a bare `%` line's empty marker matched the empty begin marker of a section that has none. From e88d1b6380d4efabdb681c9572b63550ee5ab5c7 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:34:10 -0500 Subject: [PATCH 12/71] EPS/.ai editing data: paths keep art styles of several fills and strokes live Only type kept an art style that stacks several fills or strokes, or gives one its own opacity or blend mode, as a live appearance; paths came in as their drawn look. A path or compound path now takes such a style (solid colours and gradients, strokes without a brush, no effects on the items, object effects any object takes that don't move the outline) when every gradient is its own swatch painted by the drawn look, which gives each its placement. Anything else keeps the drawn look. --- crates/eps/src/import/ai/read/ops.rs | 17 ++++++++- crates/eps/src/import/ai/style.rs | 16 +++++++- crates/eps/src/import/ai/tests.rs | 56 ++++++++++++++++++++++++++++ docs/file-format-parity.md | 3 +- 4 files changed, 88 insertions(+), 4 deletions(-) diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index dc3089024..30637f181 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -985,10 +985,12 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p use crate::import::ai::style::StyleItem; // A text object's slot (an empty group standing for it) takes the style as the type will. let slot = slot_of(object.name.as_deref()).is_some(); + // Several fills and strokes, on a path or a compound path. + let stacked = !slot && !st.simple && st.stacked && matches!(object.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); // A symbol instance draws its symbol's art: it keeps its drawn look. if !st.plain || matches!(object.kind, NodeKind::SymbolInstance { .. }) - || (!slot && !st.simple) + || (!slot && !st.simple && !stacked) || (object.is_container() && !slot && !matches!(object.kind, NodeKind::Compound { .. }) && !st.items.is_empty()) || (st.pathfinder && (slot || !st.items.is_empty() || object.is_compound_shape() || !matches!(object.kind, NodeKind::Group { clip: false, .. }))) @@ -1003,6 +1005,19 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p // drawn look has each where the app placed it. let mut placed = HashMap::new(); placed_paints(look, &mut placed, 0); + // A stack's gradients are each its own swatch, placed where the drawn look has it: one fitted + // to the object's bounds instead could lie elsewhere, so such a stack keeps its drawn look. + if stacked { + let mut seen = HashSet::new(); + let placed_apart = st.items.iter().all(|i| match i { + StyleItem::Gradient { name: Some(n), .. } => seen.insert(n) && placed.contains_key(paints.names.get(Global::Gradient, n)), + StyleItem::Gradient { name: None, .. } => false, + _ => true, + }); + if !placed_apart { + return None; + } + } let mut items = vec![]; for (n, item) in st.items.iter().enumerate() { let mut layer = match item { diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 0e1a52a6f..31c6f9808 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -40,7 +40,8 @@ //! with a pattern or gradient too; strokes with a Calligraphic brush too), each with its own //! transparency and effects, and the effects of //! [`effect`]. Such objects are read as themselves with an appearance of those fills, strokes and -//! effects; others keep their drawn look. Effect › Pathfinder effects (`(Adobe Pathfinder)` in the +//! effects (a path's stack of several fills and strokes when its drawn look places each of its +//! gradients); others keep their drawn look. Effect › Pathfinder effects (`(Adobe Pathfinder)` in the //! object's chain) are read on a group with no fills or strokes of its own, which keeps its members. use std::collections::HashMap; @@ -141,8 +142,13 @@ pub(super) struct ArtStyle { /// Feather, Round Corners, Scribble, Convert to Shape, the Path effects (Offset Path, Outline Object, Outline Stroke) /// and some Distort & Transform effects ([`any_object_takes`]) on the object /// (and Pathfinder effects when it has no fills or strokes): what any object is read with. Type takes the other plain styles too (its drawn look - /// can't be retyped); other objects keep their drawn look, which is the app's own rendering. + /// can't be retyped), paths and compound paths [`Self::stacked`] ones; other objects keep their drawn look, which is the app's own rendering. pub simple: bool, + /// It is several fills and strokes in solid colours or gradients (strokes without a brush), + /// each with its own visibility, opacity and blend mode but no effects of its own, under only + /// the effects any object takes that don't move the outline: what a path or compound path is + /// read with when each gradient is placed as its drawn look has it (`read::ops`' `styled`). + pub stacked: bool, /// Its fills and strokes, bottom first. pub items: Vec, /// Each item's own visibility, transparency and effects, in the order of [`Self::items`]. @@ -1334,6 +1340,12 @@ fn style(filters: &[Filter]) -> ArtStyle { && (!st.moves_outline() || st.effects.len() == 1) // Pathfinder effects on a group with no fills or strokes of its own (`styled` checks the group). && (!st.pathfinder || st.items.is_empty()); + st.stacked = st.plain + && !st.pathfinder + && !st.moves_outline() + && st.items.iter().all(|i| matches!(i, StyleItem::Fill(_) | StyleItem::Gradient { .. } | StyleItem::Stroke { brush: None, .. })) + && st.looks.iter().all(|l| l.effects.is_empty()) + && st.effects.iter().all(any_object_takes); st } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index c883719e3..f6b9bbd99 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -4911,6 +4911,62 @@ fn an_object_with_a_style_this_reads_comes_in_as_itself() { assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); } +/// An art style named `Glow`: a path filled with gradient swatch `under`, then gradient swatch +/// `over` at 50 % Screen above it. +fn two_gradient_style(under: &str, over: &str) -> String { + let fill = |name: &str| { + format!( + "/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\ + /FillStyle : 0 O\n1 ({name}) 0 0 0 1 1 0 0 1 0 0 Bg\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n" + ) + }; + format!( + "/KnownStyle :\n(Glow) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n{}\ + /BasicFilter :\n(Blend Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\ + /BlendStyle : 2 0.5 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d0 XR\n/Def ;\n (BlendStyle) ,\n; /Dict ;\n /Part ,\n;\n /Part ,\n\ + {}/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n;\n", + fill(over), + fill(under) + ) +} + +#[test] +fn a_path_with_several_gradient_fills_comes_in_as_itself_where_its_look_places_them() { + // GUARDS: a path whose style stacks fills with their own opacity and blend mode came in as its drawn look. + let defs = "%AI5_BeginGradient: (A)\n(A) 0 2 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 0 0 2 1 6 50 0 Bs\n%_0 0 0 0 0 0 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n\ + %AI5_BeginGradient: (B)\n(B) 0 2 Bd\n[\n<00FF>\n0\n%_0 0 0 0 0 1 0 2 1 6 50 0 Bs\n%_0 0 0 0 1 1 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n"; + let rect = rect_path(0.0, 0.0, 100.0, 50.0); + let hidden_rect = rect.replace('\n', "\n%_"); + // The drawn look: the path filled with each gradient where the app placed it (its transparency + // left out: only where the gradients lie is read from it). + let look = |name: &str, x0: f64| format!("{rect}Bb\n1 ({name}) {x0} 25 0 50 1 0 0 1 0 0 Bg\nf\n0 BB\n"); + let square = |under: &str, over: &str| format!("0 Ae\nu\n{}{}U\n%_{hidden_rect}f\n1 (Glow) XW\n", look(under, 0.0), look(over, 50.0)); + let r = read_ok(&format!("%AI9_BeginArtStyles\n{}%AI9_EndArtStyles\n{defs}{}", two_gradient_style("A", "B"), layer(&square("A", "B")))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look: {a:#?}"); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); + let fills: Vec<_> = a[0] + .appearance + .items + .iter() + .map(|i| match (i, i.paint()) { + (AppearanceItem::Fill(f), Paint::Gradient(g)) => (g.swatch.clone(), g.geom.map(|g| g.start.x), f.opacity, f.blend), + _ => panic!("{i:?}"), + }) + .collect(); + assert_eq!(fills, [(Some("A".into()), Some(0.0), 1.0, BlendMode::Normal), (Some("B".into()), Some(50.0), 0.5, BlendMode::Screen)]); + // Two fills of one swatch: which placement is whose isn't known, so the drawn look stays. + let same = read_ok(&format!("%AI9_BeginArtStyles\n{}%AI9_EndArtStyles\n{defs}{}", two_gradient_style("A", "A"), layer(&square("A", "A")))); + assert!(same.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", same.warnings); + // A look that doesn't paint the gradient (a raster, say): the drawn look stays. + let flat = format!("0 Ae\nu\n{RED}{rect}f\nU\n%_{hidden_rect}f\n1 (Glow) XW\n"); + let flat = read_ok(&format!("%AI9_BeginArtStyles\n{}%AI9_EndArtStyles\n{defs}{}", two_gradient_style("A", "B"), layer(&flat))); + assert!(flat.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", flat.warnings); +} + /// An art style named `name` (its bytes as written) that fills with colour `xa` (`c m y k r g b Xa`). fn fill_style(name: &str, xa: &str) -> String { fill_style_with(name, xa, "") diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d3e2f0303..d6e325efc 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (EPS/`.ai` editing data: paths keep art styles of several fills and strokes live) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -191,6 +191,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | EPS and `.ai` editing data: a path or compound path whose art style stacks several fills and strokes (solid colours and gradients, each with its own visibility, opacity and blend mode, under effects any object takes) comes in as itself with that appearance instead of its drawn look. Each gradient must be its own swatch, placed as the drawn look places it; a stack that repeats a swatch, or whose look doesn't paint its gradients, keeps the drawn look, as do fills and strokes with effects of their own. | | 2026-11-06 | minor | SVG import: filter and mask regions clip (SVG 1.1 §15.5, §14.4). An object whose imported blur, shadow or glow reaches past its filter's region comes in inside a clip group cut to the region (#5876), and a mask's art is cut to the mask's region, in user space or the object's bounding box (#5875). Objects that fit their regions, and our own exports, are unchanged; primitive subregions are still ignored. Tests `import_live::a_blur_is_cut_at_its_filter_region`, `import_live::a_mask_counts_only_inside_its_region` (crate `vectorcraft-svg`); scores unchanged | | 2026-11-06 | minor | SVG import: a Gaussian blur keeps the colour space its filter asks for with `color-interpolation-filters` (initially `linearRGB`, SVG 1.1 §15.7.6): a linear-light blur becomes Gaussian Blur with the new `linear` parameter, which blurs the colours in linear light (black beside white meets at about 188/255 instead of 128), and SVG export writes it back on the `feGaussianBlur` (#5877). Blurs that ask for sRGB, and the blurs our own export writes, are as before. CSS export still writes `blur()`. Tests `pixel::tests::linear_light_blur_mixes_light_not_values` (crate `vectorcraft-effects`), `import_live::a_gaussian_blur_keeps_its_colour_space` (crate `vectorcraft-svg`); scores unchanged | | 2026-11-06 | minor | PDF export and PDF-compatible `.ai` saves write each object's URL (Attributes panel) as a link annotation with a URI action (web and mail URLs only) over the bounds of the object that shows, trimmed by its clipping groups (ISO 32000-1, 12.5.6.5 and 12.6.4.7); hidden objects, layers that don't print and prints have none, and PDF/X files leave them out with a warning (#5785). Own-fixture tests in `tests::links`; PDF import doesn't read links back as URLs yet; scores unchanged | From 0f0e707e60e42d6629d85680204c2ca8493e2975 Mon Sep 17 00:00:00 2001 From: M Balazs PC Date: Sat, 7 Nov 2026 16:57:21 +0100 Subject: [PATCH 13/71] Type: a line a soft return ends is justified as a wrapped line is In Justify Left, Center or Right, only the paragraph's last line keeps its alignment; a line that a soft return (U+2028) ends is spread to the frame's edges like any wrapped line. Illustrator's own layout in a user's .ai sets such lines justified, and VectorCraft left them ragged. --- crates/text/src/layout/flow.rs | 4 +++- crates/text/src/tests/typo.rs | 23 ++++++++++++++++------- docs/type-parity.md | 5 +++-- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 112dabb23..f46c1c23a 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -543,7 +543,9 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData // Justification adds nothing to the list marker's glyphs. let mut held = held; held.extend(index..paragraph.marker.min(body)); - let last_of_para = position.end >= n || position.end.checked_sub(1).and_then(|i| sg.get(i)).is_some_and(|g| g.ch == '\u{2028}'); + // A line a soft return ends is justified as a wrapped one is: only the paragraph's last line + // keeps the alignment of Justify Left, Center or Right. + let last_of_para = position.end >= n; LineGeometry { position, index, diff --git a/crates/text/src/tests/typo.rs b/crates/text/src/tests/typo.rs index 8f37e5905..ee629ba4a 100644 --- a/crates/text/src/tests/typo.rs +++ b/crates/text/src/tests/typo.rs @@ -1970,14 +1970,23 @@ fn soft_return_breaks_line_without_new_paragraph() { assert_eq!(&t.plain_text()[l1.start..l1.end], "Line 2"); } +/// A line ending in a soft return is justified as a wrapped line is: only the paragraph's last +/// line keeps its alignment. Illustrator's own layout in a user's `.ai` (used locally, not +/// committed) spreads such lines to the frame's edge. #[test] -fn soft_return_in_justified_text_does_not_spread() { - let text = "Short\u{2028}A much longer second line of text inside the frame."; - let t = area(text, style(12.0), Rect::new(0.0, 0.0, 300.0, 200.0), Justify::JustifyLeft); - let lay = layout(db(), &t); - assert!(lay.lines.len() >= 2); - let l0 = &lay.lines[0]; - assert!(l0.x1 - l0.x0 < 100.0, "short line ending with soft return is not justified: {}", l0.x1 - l0.x0); +fn soft_return_in_justified_text_spreads_like_a_wrapped_line() { + let frame = Rect::new(0.0, 0.0, 300.0, 200.0); + let text = "A line of a few words\u{2028}and the last line."; + for justify in [Justify::JustifyLeft, Justify::JustifyCenter, Justify::JustifyRight] { + let lay = layout(db(), &area(text, style(12.0), frame, justify)); + assert_eq!(lay.lines.len(), 2); + let (l0, l1) = (&lay.lines[0], &lay.lines[1]); + assert!((l0.x0 - frame.x0).abs() < 1e-6 && (l0.x1 - frame.x1).abs() < 1e-6, "{justify:?}: {l0:?}"); + assert!(l1.x1 - l1.x0 < 200.0, "{justify:?}: the last line isn't justified: {l1:?}"); + } + // Aligned type stays aligned. + let lay = layout(db(), &area(text, style(12.0), frame, Justify::Left)); + assert!(lay.lines[0].x1 < frame.x1 - 100.0); } #[test] diff --git a/docs/type-parity.md b/docs/type-parity.md index b36cb4892..6100f028a 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Indian-language hyphenation review and license allowlist, #4947; Bulgarian among the languages, with its quote marks and the fonts' Bulgarian forms, #5945; previously minor: Show Snap to Glyph Options scrolls to the options, with a Snap to Glyph checkbox, #5273; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (a soft return in justified type no longer keeps its line from being justified; Indian-language hyphenation review and license allowlist, #4947; Bulgarian among the languages, with its quote marks and the fonts' Bulgarian forms, #5945; previously minor: Show Snap to Glyph Options scrolls to the options, with a Snap to Glyph checkbox, #5273; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -19,7 +19,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Feature | Status | Notes | |---|---|---| | Mouse wheel over the closed font-family field | done | #5253: Character, Properties and Control step selected text through the family list using saved font filters (excluding search text), one undo step per notch; the style is matched and the list stops at either end. Escape and leaving the field keep the change. Order, bounds and Undo grouping are VectorCraft's choices; scores unchanged. | -| Soft return (forced line break, Shift+Enter) | done | Shift+Enter inserts a soft return (U+2028), breaking the line without starting a new paragraph; paragraph spacing (space before/after) and first-line indents apply only at paragraph ends; continuation lines in lists do not repeat the marker; soft return lines in justified text do not spread (#2754) | +| Soft return (forced line break, Shift+Enter) | done | Shift+Enter inserts a soft return (U+2028), breaking the line without starting a new paragraph; paragraph spacing (space before/after) and first-line indents apply only at paragraph ends; continuation lines in lists do not repeat the marker; in justified text a line a soft return ends is justified as a wrapped line is, only the paragraph's last line keeping its alignment, as Illustrator's own layout in a user's `.ai` sets it (evidence: `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`; first done the other way in #2754) | | Native font-family labels | partial | With Show Font Names in English off, localized legacy names are used when typographic names contain no native label (#2208); loaded-font and catalog-only regressions. Choosing among multiple native languages is unchanged. | | Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802); `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; the Type and Vertical Type tools make area type when a closed path's outline is clicked and type on a path when an open one is, with pointers that say which, as the public help describes ([Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html), [Create type on a path](https://web.archive.org/web/20220127090635/https://helpx.adobe.com/illustrator/using/creating-type-path.html)) | | Area Type Options (rows, columns, inset, first baseline, Text Flow by rows or by columns, Auto Size, Shrink Text to Fit) | done | Text Flow fills the cells down each column first (By Columns, the default, as files from before it open) or across each row first (By Rows), in the dialog and `text.areaOptions {flow}` (#4978); the commands that make or restyle type say when it does not fit its frame; MCP `add_text` takes Auto Size and Shrink Text to Fit (#1471) | @@ -79,6 +79,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | Soft return: in Justify Left, Center or Right, the line a soft return ends is justified as a wrapped line is; only the paragraph's last line keeps its alignment. Illustrator's own layout in a user's `.ai` (used locally, not committed) justifies those lines. Scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-06 | minor | Alt + numeric-keypad codes (`Alt+0176` → °) type their character at the caret on Windows, and `type.insert` inserts a character by `code` or `unicode` (#5108); scores and estimates unchanged | | 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: each glyph's box gives four corners a drawn point lands on, also on a curved side (`B`), and its box and edges turn with rotated type (#5112); scores and estimates unchanged | From eb03eacba6b24f37ef95751ef7d87480ba37e1db Mon Sep 17 00:00:00 2001 From: M Balazs PC Date: Sat, 7 Nov 2026 16:57:21 +0100 Subject: [PATCH 14/71] .ai/EPS import: type keeps auto leading, hyphenation options and the file's last lines - Auto leading stays auto: each line takes the paragraph's share (key 7) of the largest type on it. The leading was the first two lines' distance for every line, so 10 pt lines under a 15 pt heading came in 18 pt apart. - The paragraph's hyphenation options (keys 10 to 15) are read. - Area type of one frame whose last lines the file sets, but which don't fit here (the fallback font, or a descent past the frame's bottom), gets a frame taller from its bottom (vectorcraft_text::fit_source_lines), with a warning. A user's .ai whose type differed from its page by 6.6%, so that it opened from its PDF part with each line its own text object, now opens from its layers. --- crates/eps/src/import/ate.rs | 233 ++++++++++++++++++++------- crates/eps/src/import/native/plan.rs | 12 +- crates/text/src/layout.rs | 2 +- crates/text/src/layout/driver.rs | 32 ++++ crates/text/src/lib.rs | 2 +- crates/text/src/tests.rs | 32 ++++ docs/file-format-parity.md | 7 +- 7 files changed, 258 insertions(+), 62 deletions(-) diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 1e2347249..da698f3ec 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -24,7 +24,9 @@ use std::collections::BTreeMap; use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{CharStyle, Justify, Node, NodeId, NodeKind, ParaDirection, ParaStyle, TextKind, TextObject, TextRun}; +use vectorcraft_doc::{ + CharStyle, HyphenationOptions, JustificationOptions, Justify, Node, NodeId, NodeKind, ParaDirection, ParaStyle, TextKind, TextObject, TextRun, +}; use vectorcraft_geom::{Affine, PathData, Point, Rect}; /// Longest story read (bytes). @@ -343,6 +345,13 @@ struct Para { space_before: f64, space_after: f64, hyphenate: bool, + /// Key 7: the share of its type size a line takes as its auto leading (1.2: 120%). `None`: + /// the default. + auto_leading: Option, + /// Keys 10 to 15, in the order of Paragraph panel menu › Hyphenation…: Words Longer Than, + /// After First, Before Last, Hyphen Limit, Hyphenation Zone (pt) and Hyphenate Capitalized + /// Words. A value that can't be read, or is out of its range, keeps the default. + hyphenation: HyphenationOptions, } /// The text of one story, in the units of the text document. @@ -588,6 +597,24 @@ impl Texts { space_before: num("4"), space_after: num("5"), hyphenate: !matches!(pick("9"), Some(Val::Bool(false))), + auto_leading: pick("7").and_then(Val::num).filter(|v| v.is_finite() && *v > 0.0 && *v <= 5.0), + hyphenation: { + let d = HyphenationOptions::default(); + let count = |k: &str, lo: f64, hi: f64, or: u32| { + pick(k).and_then(Val::num).filter(|v| v.is_finite() && v.fract() == 0.0 && (lo..=hi).contains(v)).map_or(or, |v| v as u32) + }; + HyphenationOptions { + words_longer_than: count("10", 1.0, 25.0, d.words_longer_than), + after_first: count("11", 1.0, 15.0, d.after_first), + before_last: count("12", 1.0, 15.0, d.before_last), + hyphen_limit: count("13", 0.0, 25.0, d.hyphen_limit), + zone: pick("14").and_then(Val::num).filter(|v| v.is_finite() && (0.0..=1296.0).contains(v)).unwrap_or(d.zone), + capitalized: match pick("15") { + Some(Val::Bool(b)) => *b, + _ => d.capitalized, + }, + } + }, } } @@ -882,7 +909,6 @@ impl Story { // The text document measures in the art's units too: the document's scale carries its // sizes and spacings onto the page (a Large Canvas copy is written at 1:10; #2031). let scale = scale * art_scale(to_doc); - let leading = self.line_leading(scale); let chars: Vec = text.chars().collect(); let mut runs = vec![]; let mut from = 0usize; @@ -893,13 +919,13 @@ impl Story { if piece.is_empty() { continue; } - runs.push(TextRun::new(piece, char_style(st, scale, leading))); + runs.push(TextRun::new(piece, char_style(st, scale))); } if from < chars.len() { let last = self.runs.last().map(|r| &r.1)?; - runs.push(TextRun::new(chars.get(from..).unwrap_or_default().iter().collect::(), char_style(last, scale, leading))); + runs.push(TextRun::new(chars.get(from..).unwrap_or_default().iter().collect::(), char_style(last, scale))); } - let first = self.runs.first().map(|r| char_style(&r.1, scale, leading))?; + let first = self.runs.first().map(|r| char_style(&r.1, scale))?; let mut t = TextObject::point(Point::ZERO, "", first); if !runs.is_empty() { t.runs = runs; @@ -930,6 +956,15 @@ impl Story { !self.lines.is_empty() && vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), t) } + /// Area type of one frame that the file lays more lines out in than fit here: in the fallback + /// font, or a last line whose descent the file lets pass the frame's bottom. The frame is made + /// taller from its bottom (`vectorcraft_text::fit_source_lines`) so the file's lines show. + /// Whether it was. + pub(super) fn fit_last_lines(&self, t: &mut TextObject) -> bool { + matches!(self.frames.as_slice(), [FrameKind::Area(..)]) + && vectorcraft_text::fit_source_lines(vectorcraft_text::FontDb::global(), t, self.lines.len()) + } + /// Area type (`node`, the story's first frame as [`Self::node`] makes it with `template` and /// `to_doc`) whose first line the file lays out higher or lower than it is laid out here, as its /// page draws it: its first baseline is fixed where the file has it. The font's ascent below the @@ -968,38 +1003,6 @@ impl Story { true } - /// The leading the file's own line positions imply: the distance from one line to the next, less - /// the space between paragraphs that is part of it (a paragraph's space after and the next one's - /// space before; a layout's lines are where that space has put them, and the text object adds it - /// again). - fn line_leading(&self, scale: f64) -> Option { - let para_at = |c: usize| { - let mut end = 0usize; - for (n, p) in &self.paras { - end = end.saturating_add(*n); - if c < end { - return Some(p); - } - } - self.paras.last().map(|(_, p)| p) - }; - let chars: Vec = self.text.chars().collect(); - let breaks: Vec = chars.iter().enumerate().filter(|(_, c)| matches!(c, '\r' | '\x03')).map(|(i, _)| i).collect(); - // More lines than breaks: area type wraps, and which break ends which line is unknown. - let wraps = self.lines.len() > breaks.len() + 1; - self.lines.windows(2).enumerate().find_map(|(i, w)| { - let pitch = w[1].1 - w[0].1; - let between = match breaks.get(i) { - Some(&at) if !wraps && chars.get(at) == Some(&'\r') => { - para_at(at).map_or(0.0, |p| p.space_after) + para_at(at + 1).map_or(0.0, |p| p.space_before) - } - _ => 0.0, - }; - let l = (pitch - between) * scale; - (l.is_finite() && l > 0.0).then_some(l) - }) - } - /// The style of each paragraph of `text` (paragraphs split at `\n`). fn paragraph_styles(&self, text: &str, scale: f64) -> Vec { let count = text.split('\n').count(); @@ -1049,21 +1052,29 @@ fn para_style(p: &Para, scale: f64) -> ParaStyle { space_before: p.space_before * scale, space_after: p.space_after * scale, hyphenate: p.hyphenate, + hyphenation: HyphenationOptions { zone: p.hyphenation.zone * scale, ..p.hyphenation.clone() }, // A right-to-left paragraph lays its text out from the right whatever letters it has // (" 120.5 mm " reads "mm 120.5"); without the key, from its first strong letter. direction: p.rtl.then_some(ParaDirection::RightToLeft), + justification: JustificationOptions { + // Rounded, so the file's 1.2 is the default 120% exactly. + auto_leading: p.auto_leading.map_or(JustificationOptions::default().auto_leading, |a| (a * 1e6).round() / 1e4), + ..JustificationOptions::default() + }, ..ParaStyle::default() } } -fn char_style(s: &Style, scale: f64, leading: Option) -> CharStyle { +fn char_style(s: &Style, scale: f64) -> CharStyle { let (family, style) = s.font.as_deref().map_or_else(|| (CharStyle::default().font_family, "Regular".to_string()), crate::family_style); let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family); CharStyle { font_family: family, font_style: style, size: s.size * scale, - leading: s.leading.map(|l| l * scale).or(leading), + // Auto leading stays auto: the layout gives each line the paragraph's share of the largest + // type on it, as the file's own layout does. + leading: s.leading.map(|l| l * scale), tracking: s.tracking, baseline_shift: s.baseline_shift * scale, h_scale: s.h_scale * 100.0, @@ -1111,6 +1122,63 @@ mod tests { assert_eq!(justify(b"/0 1 /33 0"), Justify::Right); } + /// Key 7 is the paragraph's auto leading, a share of the type size; one that can't be read + /// leaves the default, 120%. + #[test] + fn a_paragraph_keeps_its_auto_leading() { + let texts = Texts { + stories: vec![], + fonts: vec![], + defaults: Val::Dict(BTreeMap::new()), + para_defaults: read(b"/7 1.2").unwrap(), + char_sheets: vec![], + para_sheets: vec![], + frames: vec![], + }; + let auto = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).justification.auto_leading; + assert_eq!(auto(b""), JustificationOptions::default().auto_leading); + assert_eq!(auto(b"/7 1.5"), 150.0); + assert_eq!(auto(b"/7 1.15"), 115.0); + for unreadable in [b"/7 0".as_slice(), b"/7 -1", b"/7 1e9", b"/7 /nil"] { + assert!((auto(unreadable) - 120.0).abs() < 1e-9, "{}", String::from_utf8_lossy(unreadable)); + } + } + + /// Keys 10 to 15 are the paragraph's hyphenation options; one that can't be read, or is out of + /// its range, keeps the default. The zone is in points of the text document, scaled as sizes are. + #[test] + fn a_paragraph_keeps_its_hyphenation_options() { + let texts = Texts { + stories: vec![], + fonts: vec![], + defaults: Val::Dict(BTreeMap::new()), + para_defaults: read(b"/10 6 /11 2 /12 2 /13 0 /14 36.0 /15 true").unwrap(), + char_sheets: vec![], + para_sheets: vec![], + frames: vec![], + }; + let options = |own: &[u8], scale: f64| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), scale).hyphenation; + let want = HyphenationOptions { words_longer_than: 6, after_first: 2, before_last: 2, hyphen_limit: 0, zone: 36.0, capitalized: true }; + assert_eq!(options(b"", 1.0), want); + assert_eq!( + options(b"/10 8 /13 3 /15 false", 0.5), + HyphenationOptions { words_longer_than: 8, hyphen_limit: 3, zone: 18.0, capitalized: false, ..want.clone() } + ); + let d = HyphenationOptions::default(); + let bare = Texts { + stories: vec![], + fonts: vec![], + defaults: Val::Dict(BTreeMap::new()), + para_defaults: Val::Dict(BTreeMap::new()), + char_sheets: vec![], + para_sheets: vec![], + frames: vec![], + }; + let own = |own: &[u8]| para_style(&bare.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).hyphenation; + assert_eq!(own(b""), d); + assert_eq!(own(b"/10 0 /11 99 /12 2.5 /13 -1 /14 -3 /15 1"), d); + } + /// Where point type lies runs along its baseline: a label turned a quarter (as a vertical /// dimension's is) lies in a tall box, not a wide one. #[test] @@ -1411,25 +1479,54 @@ mod tests { } } + /// The baselines of the text object `story` comes in as, laid out. + fn baselines_of(story: &Story) -> Vec { + let Some(node) = story.node(NodeId(1), 0, (0.0, 0.0), Affine::IDENTITY) else { return vec![] }; + let NodeKind::Text(t) = &node.kind else { return vec![] }; + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).baselines().iter().map(|b| b.0.y).collect() + } + + /// Auto leading stays auto: each line takes the paragraph's share of the largest type on it, as + /// the file's own layout does, not the distance between the first two lines for every line. A + /// 15 pt heading over 10 pt type at 120%: 18 pt between the heading's lines, then 12 pt. + #[test] + fn auto_leading_follows_the_size_of_each_line() { + let mut s = story("Big\x03Bigger\rsmall\r", &[0.0, 18.0, 30.0], &Para::default()); + let base = s.runs[0].1.clone(); + s.runs = vec![(11, Style { size: 15.0, ..base.clone() }), (6, Style { size: 10.0, ..base })]; + assert_eq!(leading_of(&s), None); + let ys = baselines_of(&s); + assert_eq!(ys.len(), 3, "{ys:?}"); + assert!(((ys[1] - ys[0]).abs() - 18.0).abs() < 1e-6, "{ys:?}"); + assert!(((ys[2] - ys[1]).abs() - 12.0).abs() < 1e-6, "{ys:?}"); + // At a share the paragraph sets (key 7) of 150%: 22.5 pt, then 15 pt. + let share = Para { auto_leading: Some(1.5), ..Para::default() }; + s.paras = s.paras.iter().map(|(n, _)| (*n, share.clone())).collect(); + let ys = baselines_of(&s); + assert!(((ys[1] - ys[0]).abs() - 22.5).abs() < 1e-6, "{ys:?}"); + assert!(((ys[2] - ys[1]).abs() - 15.0).abs() < 1e-6, "{ys:?}"); + } + #[test] fn leading_leaves_out_the_space_between_paragraphs_that_the_lines_already_have() { // Lines 10.6 apart that are three paragraphs with 5 less after each: auto leading (15.6) less 5. let para = Para { space_after: -5.0, ..Para::default() }; + // The text object keeps auto leading and the spacing, so its lines are where the file's are. let s = story("one\rtwo\rthree\r", &[0.0, 10.6, 21.2], ¶); - assert!((s.line_leading(1.0).unwrap() - 15.6).abs() < 1e-9); - assert!((leading_of(&s).unwrap() - 15.6).abs() < 1e-9); - // Space after and space before both count; the scale of the frame carries onto the leading. + assert_eq!(leading_of(&s), None); + let ys = baselines_of(&s); + assert!(ys.len() == 3 && ys.windows(2).all(|w| ((w[1] - w[0]).abs() - 10.6).abs() < 1e-6), "{ys:?}"); + // Space after and space before both count. let para = Para { space_after: 4.0, space_before: 2.0, ..Para::default() }; - let s = story("one\rtwo\r", &[0.0, 20.0], ¶); - assert!((s.line_leading(1.0).unwrap() - 14.0).abs() < 1e-9); - assert!((s.line_leading(0.5).unwrap() - 7.0).abs() < 1e-9); + let ys = baselines_of(&story("one\rtwo\r", &[0.0, 21.6], ¶)); + assert!(((ys[1] - ys[0]).abs() - 21.6).abs() < 1e-6, "{ys:?}"); } /// #2031: a Large Canvas editing copy measures its text document in its art's units, a tenth /// of the page's: the story's size and leading reach the page through `to_doc`'s scale. #[test] fn a_story_in_a_scaled_art_space_keeps_its_size_and_leading() { - let s = story("one\rtwo\r", &[0.0, 20.0], &Para::default()); + let s = story("one\rtwo\r", &[0.0, 15.6], &Para::default()); // A copy written at 1:10 (see `ai::read::ensure_space`): its art's units reach the // document, 100 points to the page, through `to_doc`'s scale. let to_doc = Affine::scale(10.0) * Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 100.0]); @@ -1438,24 +1535,18 @@ mod tests { _ => panic!("not text"), }; assert_eq!(t.runs[0].style.size, 130.0, "13 pt at the page's scale"); - assert_eq!(t.runs[0].style.leading, Some(200.0), "a 20-unit line pitch at the page's scale"); + assert_eq!(t.runs[0].style.leading, None, "auto leading: 120% of its size at the page's scale"); + assert_eq!(t.para.justification.auto_leading, 120.0); } #[test] fn leading_between_lines_of_a_paragraph_is_their_distance() { // A line break inside a paragraph has no paragraph spacing between its lines. let para = Para { space_after: -5.0, space_before: 3.0, ..Para::default() }; - let s = story("one\x03two\x03three\r", &[0.0, 15.6, 31.2], ¶); - assert!((s.line_leading(1.0).unwrap() - 15.6).abs() < 1e-9); - // Paragraphs without spacing: the distance as it was. - let s = story("one\rtwo\r", &[0.0, 15.6], &Para::default()); - assert!((s.line_leading(1.0).unwrap() - 15.6).abs() < 1e-9); - // Area type whose paragraph wraps: lines and breaks don't pair up, so nothing is taken off. - let para = Para { space_after: -5.0, ..Para::default() }; - let s = story("one two three\rfour\r", &[0.0, 15.6, 31.2, 41.8], ¶); - assert!((s.line_leading(1.0).unwrap() - 15.6).abs() < 1e-9); - // One line has no distance to read. - assert_eq!(story("one\r", &[0.0], &Para::default()).line_leading(1.0), None); + let ys = baselines_of(&story("one\x03two\x03three\r", &[0.0, 15.6, 31.2], ¶)); + assert!(ys.len() == 3 && ys.windows(2).all(|w| ((w[1] - w[0]).abs() - 15.6).abs() < 1e-6), "{ys:?}"); + // One line. + assert_eq!(baselines_of(&story("one\r", &[0.0], &Para::default())).len(), 1); } /// A `\x03` ends a line inside its paragraph: it comes in as a forced line break, and the @@ -1499,6 +1590,32 @@ mod tests { assert!(!story("Heading\r", &[], &Para::default()).fit_first_line(&mut t) && height(&t).unwrap().3 == 13.0); } + /// The file sets three lines in a frame whose third line's descent passes its bottom: the frame + /// grows from its bottom so the third shows. Only when the story's one frame is area type. + #[test] + fn the_last_lines_the_file_sets_in_its_frame_show() { + let db = vectorcraft_text::FontDb::global(); + let area = |height: f64| { + let mut t = TextObject::point(Point::ZERO, "one\ntwo\nthree", CharStyle { size: 13.0, ..CharStyle::default() }); + t.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 20.0, 210.0, 20.0 + height)) }; + t + }; + let tall = vectorcraft_text::layout(db, &area(400.0)); + let third = &tall.lines[2]; + let mut t = area(third.baseline - 20.0 + third.descent / 2.0); + assert_eq!(vectorcraft_text::layout(db, &t).lines.len(), 2); + let mut s = story("one\rtwo\rthree\r", &[0.0, 15.6, 31.2], &Para::default()); + // A point-type frame (the helper's) isn't fitted. + assert!(!s.fit_last_lines(&mut t)); + s.frames = vec![FrameKind::Area(vec![], AreaFrame::default())]; + assert!(s.fit_last_lines(&mut t)); + assert_eq!(vectorcraft_text::layout(db, &t).lines.len(), 3); + // A story threaded through two frames: its lines are the first frame's alone. + let mut t = area(third.baseline - 20.0 + third.descent / 2.0); + s.frames.push(FrameKind::Area(vec![], AreaFrame::default())); + assert!(!s.fit_last_lines(&mut t)); + } + #[test] fn area_type_s_first_line_goes_where_the_file_lays_it_out() { // GUARDS: area type's first baseline was the font's ascent below the frame's top, where the diff --git a/crates/eps/src/import/native/plan.rs b/crates/eps/src/import/native/plan.rs index bac7108ac..4e1e0a004 100644 --- a/crates/eps/src/import/native/plan.rs +++ b/crates/eps/src/import/native/plan.rs @@ -59,7 +59,7 @@ pub(super) fn make_slots( to_doc: Affine, warn: &mut Vec, ) -> Vec> { - let mut taller = 0; + let (mut taller, mut deeper) = (0, 0); let made = infos .iter() .enumerate() @@ -79,6 +79,11 @@ pub(super) fn make_slots( { taller += 1; } + if let NodeKind::Text(text) = &mut node.kind + && story.fit_last_lines(text) + { + deeper += 1; + } Some((Box::new(node), region)) }) .collect(); @@ -87,6 +92,11 @@ pub(super) fn make_slots( "area type in frames too short for a line of the font it shows in got taller frames ({taller}): their type wouldn't show otherwise" )); } + if deeper > 0 { + warn.push(format!( + "area type whose last lines the file sets in frames too short for them here got taller frames ({deeper}): those lines wouldn't show otherwise" + )); + } made } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index c36514eef..74a25b4ff 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -36,7 +36,7 @@ pub(crate) use bidi::{is_rtl, para_bidi}; use breaking::tab_advance; #[cfg(test)] use breaking::{break_line, candidates}; -pub use driver::{decorations, fit_first_line, layout, layout_with, scaled}; +pub use driver::{decorations, fit_first_line, fit_source_lines, layout, layout_with, scaled}; use vertical::{TcyStyle, stands_upright, tate_chu_yoko, upright_cell, upright_centre, upright_extent, whole_character_widths}; const EPS: f64 = 1e-6; diff --git a/crates/text/src/layout/driver.rs b/crates/text/src/layout/driver.rs index 01fef1765..0c44fee58 100644 --- a/crates/text/src/layout/driver.rs +++ b/crates/text/src/layout/driver.rs @@ -62,6 +62,38 @@ pub fn fit_first_line(db: &FontDb, t: &mut TextObject) -> bool { true } +/// Area type whose source lays out `lines` lines in its frame, more than fit here: a font that isn't +/// available shows in the fallback font, whose lines may reach lower, and the source may set a last +/// line whose descent passes the frame's bottom, which isn't drawn here. For importers: the frame is +/// made taller, from its bottom, by at most a line's descent, when that lays out more of the +/// source's lines and no more than it has. Horizontal type only. Whether it was. +pub fn fit_source_lines(db: &FontDb, t: &mut TextObject, lines: usize) -> bool { + let TextKind::Area { frame } = &t.kind else { return false }; + if t.vertical { + return false; + } + let Some(b) = frame.bounds().filter(|b| b.height() > 1e-6) else { return false }; + let laid = layout(db, t); + let have = laid.lines.len(); + if !laid.overflow || have == 0 || have >= lines { + return false; + } + // The deepest descent of its lines (a little more, for the rounding of the layout). + let Some(descent) = laid.lines.iter().map(|l| l.descent).reduce(f64::max).filter(|d| d.is_finite() && *d > 0.0) else { return false }; + let by = (b.height() + descent + 0.05) / b.height(); + if !(by.is_finite() && by > 1.0) { + return false; + } + let taller = frame.transformed(Affine::translate((0.0, b.y0)) * Affine::scale_non_uniform(1.0, by) * Affine::translate((0.0, -b.y0))); + let short = std::mem::replace(&mut t.kind, TextKind::Area { frame: taller }); + let now = layout(db, t).lines.len(); + if now > have && now <= lines { + return true; + } + t.kind = short; + false +} + /// The underline and strikethrough bars of `t` laid out as `layout` (#847): per run and line, a /// bar from the stretch's first glyph to its last one's advance, placed and sized by the font's /// own underline and strikeout metrics through the glyph's transform (so the size, baseline shift diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 9763ea4b6..d4ccdcdef 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -44,7 +44,7 @@ use std::sync::Arc; pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader}; use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2}; -pub use layout::{decorations, fit_first_line, layout, layout_with, scaled}; +pub use layout::{decorations, fit_first_line, fit_source_lines, layout, layout_with, scaled}; pub use vectorcraft_doc::TextObject; pub use vectorcraft_doc::{AreaFit, FirstBaseline, TextFlow, VerticalAlign}; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 031d35bdc..e7526849e 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -304,6 +304,38 @@ fn area_alignment_and_justify() { assert!((l.lines[0].x1 - frame.x1).abs() < 1e-6); } +/// A source that sets a last line whose descent passes its frame's bottom: the frame grows from its +/// bottom by at most a line's descent, so that line shows, and only as many lines as the source has. +#[test] +fn fit_source_lines_keeps_the_last_lines_the_source_sets() { + let text = "one\ntwo\nthree"; + let tall = layout(db(), &area(text, style(12.0), Rect::new(0.0, 0.0, 200.0, 400.0), Justify::Left)); + let third = &tall.lines[2]; + // The third baseline is in the frame, its descent isn't. + let frame = Rect::new(0.0, 0.0, 200.0, third.baseline + third.descent / 2.0); + let mut t = area(text, style(12.0), frame, Justify::Left); + let short = layout(db(), &t); + assert!(short.overflow && short.lines.len() == 2); + // A source with no more lines than fit leaves the frame as it is. + assert!(!fit_source_lines(db(), &mut t, 2)); + assert_eq!(layout(db(), &t).lines.len(), 2); + assert!(fit_source_lines(db(), &mut t, 3)); + let fitted = layout(db(), &t); + assert!(!fitted.overflow && fitted.lines.len() == 3); + let TextKind::Area { frame: grown } = &t.kind else { panic!("area type") }; + let grown = grown.bounds().unwrap(); + assert!(grown.y0 == frame.y0 && grown.y1 > frame.y1 && grown.y1 <= frame.y1 + third.descent + 0.1, "{grown:?}"); + // Fitted, nothing more to do. + assert!(!fit_source_lines(db(), &mut t, 3)); + // More than a descent short: a line the source can't have set in its frame stays out. + let frame = Rect::new(0.0, 0.0, 200.0, tall.lines[1].baseline + tall.lines[1].descent + 1.0); + let mut t = area(text, style(12.0), frame, Justify::Left); + assert!(!fit_source_lines(db(), &mut t, 3)); + // Point type and vertical type aren't fitted. + let mut p = point(text, style(12.0)); + assert!(!fit_source_lines(db(), &mut p, 9)); +} + #[test] fn indents_and_paragraph_spacing() { let frame = Rect::new(0.0, 0.0, 200.0, 400.0); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f60d46f62..8f3eb92b8 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (EPS and `.ai` editing data: a transparency set inside a clipping group stays after its `Q`; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (`.ai` and EPS type from the text document keeps auto leading, hyphenation options and the file's last lines; EPS and `.ai` editing data: a transparency set inside a clipping group stays after its `Q`; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -49,6 +49,10 @@ private editing data. VectorCraft: clipped by a copy of the mask's path. An opacity mask's art of one shown object, other than a guide or a clipping path, opens as that object in the editing data of a `.ai`, EPS or PDF; other art opens in a group. + Type from the text document keeps auto leading as auto, each line taking the paragraph's share + (Auto Leading) of the largest type on it, and the paragraph's hyphenation options. Area type of one + frame whose last lines the file sets but which don't fit here (the fallback font, or a descent + past the frame's bottom) gets a frame taller from its bottom, with a warning. RGB in the PDF page's `ICCBased` spaces keeps the file's numbers in a `.ai` (the `uncalibrated_rgb` import option, on for editor documents, off for PDFs), so `.ai` files tagged with a non-sRGB RGB profile open with different, unconverted RGB colours than before. @@ -212,6 +216,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | EPS and `.ai` type from the text document: auto leading stays auto at the paragraph's share (it was the first two lines' distance for every line, so 10 pt lines under a 15 pt heading came in 18 pt apart), the paragraph's hyphenation options are read, and area type whose last lines the file sets in too short a frame for them here gets a taller frame. A user's `.ai` (used locally, not committed) whose type differed from its page by 6.6%, so that it opened from its PDF part, now opens from its layers (4.9%, the rest its Myriad Pro type in the fallback font). Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: `Q` restores the painting state but not the transparency (`Xy`), which the writer only sets when the next object's differs from the last one written. A Screen glow followed by a clipping group set back to Normal no longer leaves the type after the group in Screen: two users' Illustrator 26.5 and 30 EPS posters (used locally, not committed) now show their gold heading and crescent, and both open from their layers (2.5% and 2.7% from the page before, under the limit after). Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: a blend whose spine was never edited, written with its keys and no spine part, comes in as a live blend along the straight lines between its keys' centres (in its record's order) instead of as its drawn steps. Checked on six users' Illustrator EPS files (used locally, not committed): every blend in four of them now comes in live, their renders unchanged; two still have blends the reader can't match to their drawn steps. Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: the art styles are found before the art is read, so objects in symbols defined ahead of `%AI9_BeginArtStyles` (as Illustrator 24 writes it) come in as themselves with their style instead of their drawn look. Checked on two users' EPS files (used locally, not committed): one's Symbols panel gains live Feather and Round Corners, its drawn-look count drops from 5 to 2, and both render unchanged; own-fixture test in `import::ai::tests`; scores and hours unchanged | From 92726632284173b8025135522b1596db82c2093f Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:14:20 -0500 Subject: [PATCH 15/71] EPS/.ai editing data: a stack's one gradient lies where its drawn look has it A stacked style with a single gradient took the object's own gradient fill whenever the object had one, even where the drawn look placed the gradient elsewhere. A stack now always takes the placement of its drawn look, which it already requires for each of its gradients. --- crates/eps/src/import/ai/read/ops.rs | 5 +++-- crates/eps/src/import/ai/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 764598dc6..57eac4d15 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -1051,8 +1051,9 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p StyleItem::Gradient { name, angle } => { // The drawn look's gradients are keyed by the names they got in the document. let paint = match (name.as_ref().and_then(|n| placed.get(paints.names.get(Global::Gradient, n))), gradients, own_gradient) { - // Other objects than type: the object's own, as they always were. - (_, 1, Some(own)) if !slot => own.paint.clone(), + // Other objects than type: the object's own, as they always were (a stack's + // where its drawn look places it). + (_, 1, Some(own)) if !slot && !stacked => own.paint.clone(), (Some(p), ..) => p.clone(), (None, 1, Some(own)) => own.paint.clone(), _ => { diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index ddce056fd..43c24c0e1 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -5346,6 +5346,26 @@ fn a_path_with_several_gradient_fills_comes_in_as_itself_where_its_look_places_t // Two fills of one swatch: which placement is whose isn't known, so the drawn look stays. let same = read_ok(&format!("%AI9_BeginArtStyles\n{}%AI9_EndArtStyles\n{defs}{}", two_gradient_style("A", "A"), layer(&square("A", "A")))); assert!(same.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", same.warnings); + // One gradient over a solid fill, on a path filled with that gradient placed elsewhere: the + // gradient lies where the drawn look has it, not where the path's own fill does. + let one = two_gradient_style("A", "B").replace("1 (A) 0 0 0 1 1 0 0 1 0 0 Bg\n", "0 0 0 1 0 0 0 Xa\n"); + let own = format!( + "0 Ae\nu\n0 0 0 1 0 0 0 Xa\n{rect}f\n{}U\n%_{hidden_rect}Bb\n%_1 (B) 0 25 0 100 1 0 0 1 0 0 Bg\n%_f\n%_0 BB\n1 (Glow) XW\n", + look("B", 50.0) + ); + let one = read_ok(&format!("%AI9_BeginArtStyles\n{one}%AI9_EndArtStyles\n{defs}{}", layer(&own))); + let a = art(&one.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let placed: Vec<_> = a[0] + .appearance + .items + .iter() + .map(|i| match i.paint() { + Paint::Gradient(g) => g.geom.map(|g| g.start.x), + _ => None, + }) + .collect(); + assert_eq!(placed, [None, Some(50.0)], "{:?}", one.warnings); // A look that doesn't paint the gradient (a raster, say): the drawn look stays. let flat = format!("0 Ae\nu\n{RED}{rect}f\nU\n%_{hidden_rect}f\n1 (Glow) XW\n"); let flat = read_ok(&format!("%AI9_BeginArtStyles\n{}%AI9_EndArtStyles\n{defs}{}", two_gradient_style("A", "B"), layer(&flat))); From b4199559f22868d33c5e22bfff08db0049d9f8c7 Mon Sep 17 00:00:00 2001 From: M Balazs PC Date: Sat, 7 Nov 2026 17:17:00 +0100 Subject: [PATCH 16/71] .ai/EPS import: cite Illustrator's documentation of auto leading --- crates/eps/src/import/ate.rs | 8 +++++--- docs/file-format-parity.md | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index da698f3ec..f3061bb75 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -345,8 +345,9 @@ struct Para { space_before: f64, space_after: f64, hyphenate: bool, - /// Key 7: the share of its type size a line takes as its auto leading (1.2: 120%). `None`: - /// the default. + /// Key 7: the share of its type size a line takes as its auto leading (1.2: 120%, the default; + /// Justification… sets 0 to 500%: + /// https://helpx.adobe.com/illustrator/using/line-character-spacing.html). `None`: the default. auto_leading: Option, /// Keys 10 to 15, in the order of Paragraph panel menu › Hyphenation…: Words Longer Than, /// After First, Before Last, Hyphen Limit, Hyphenation Zone (pt) and Hyphenate Capitalized @@ -1073,7 +1074,8 @@ fn char_style(s: &Style, scale: f64) -> CharStyle { font_style: style, size: s.size * scale, // Auto leading stays auto: the layout gives each line the paragraph's share of the largest - // type on it, as the file's own layout does. + // type on it, as the file's own layout does ("The largest leading value in a line of type + // determines the leading for that line": https://helpx.adobe.com/illustrator/using/line-character-spacing.html). leading: s.leading.map(|l| l * scale), tracking: s.tracking, baseline_shift: s.baseline_shift * scale, diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 8f3eb92b8..109d40796 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -50,7 +50,8 @@ private editing data. VectorCraft: An opacity mask's art of one shown object, other than a guide or a clipping path, opens as that object in the editing data of a `.ai`, EPS or PDF; other art opens in a group. Type from the text document keeps auto leading as auto, each line taking the paragraph's share - (Auto Leading) of the largest type on it, and the paragraph's hyphenation options. Area type of one + (Auto Leading) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)), + and the paragraph's hyphenation options. Area type of one frame whose last lines the file sets but which don't fit here (the fallback font, or a descent past the frame's bottom) gets a frame taller from its bottom, with a warning. RGB in the PDF page's `ICCBased` spaces keeps the file's numbers in a `.ai` (the `uncalibrated_rgb` From f2c1df1ec3651cb0c5913926c77db33ebf200bfd Mon Sep 17 00:00:00 2001 From: M Balazs PC Date: Sat, 7 Nov 2026 17:43:42 +0100 Subject: [PATCH 17/71] .ai/EPS import: the parity note says what still differs in the file it was checked on --- docs/file-format-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 109d40796..57ced6c12 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -217,7 +217,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| -| 2026-11-07 | minor | EPS and `.ai` type from the text document: auto leading stays auto at the paragraph's share (it was the first two lines' distance for every line, so 10 pt lines under a 15 pt heading came in 18 pt apart), the paragraph's hyphenation options are read, and area type whose last lines the file sets in too short a frame for them here gets a taller frame. A user's `.ai` (used locally, not committed) whose type differed from its page by 6.6%, so that it opened from its PDF part, now opens from its layers (4.9%, the rest its Myriad Pro type in the fallback font). Scores and estimates unchanged. | +| 2026-11-07 | minor | EPS and `.ai` type from the text document: auto leading stays auto at the paragraph's share (it was the first two lines' distance for every line, so 10 pt lines under a 15 pt heading came in 18 pt apart), the paragraph's hyphenation options are read, and area type whose last lines the file sets in too short a frame for them here gets a taller frame. A user's `.ai` (used locally, not committed) whose type differed from its page by 6.6%, so that it opened from its PDF part, now opens from its layers (4.9%; type with tab stops in right-aligned paragraphs still differs, a separate issue) (#6137). Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: `Q` restores the painting state but not the transparency (`Xy`), which the writer only sets when the next object's differs from the last one written. A Screen glow followed by a clipping group set back to Normal no longer leaves the type after the group in Screen: two users' Illustrator 26.5 and 30 EPS posters (used locally, not committed) now show their gold heading and crescent, and both open from their layers (2.5% and 2.7% from the page before, under the limit after). Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: a blend whose spine was never edited, written with its keys and no spine part, comes in as a live blend along the straight lines between its keys' centres (in its record's order) instead of as its drawn steps. Checked on six users' Illustrator EPS files (used locally, not committed): every blend in four of them now comes in live, their renders unchanged; two still have blends the reader can't match to their drawn steps. Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: the art styles are found before the art is read, so objects in symbols defined ahead of `%AI9_BeginArtStyles` (as Illustrator 24 writes it) come in as themselves with their style instead of their drawn look. Checked on two users' EPS files (used locally, not committed): one's Symbols panel gains live Feather and Round Corners, its drawn-look count drops from 5 to 2, and both render unchanged; own-fixture test in `import::ai::tests`; scores and hours unchanged | From a4e315796b481e903dd4b72905d3417db18ae579 Mon Sep 17 00:00:00 2001 From: M Balazs PC Date: Sat, 7 Nov 2026 18:49:19 +0100 Subject: [PATCH 18/71] Type: right-aligned area type sets the text after a line's last tab against the edge In right-aligned area type, the text before a line's last tab sits at its stops, measured from the left edge, and the text after it (none when the tab ends the line) against the right edge. The line was laid out from the left and aligned as a whole, which moved its start off the left edge and its tabs' text off their stops. Illustrator's own layout in a user's .ai sets its price lists so. Centred and justified lines are aligned as a whole as before. --- crates/text/src/layout/flow.rs | 47 +++++++++++++++++++++++++++++----- crates/text/src/tests.rs | 46 ++++++++++++++++++++++++++++++++- docs/type-parity.md | 5 ++-- 3 files changed, 89 insertions(+), 9 deletions(-) diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index f46c1c23a..2af953157 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -383,6 +383,9 @@ struct LineJustification { start_x: f64, after: Vec>, kashidas: Vec<(usize, f64)>, + /// Right-aligned area type: the line's last tab, which takes the text after it to the right + /// edge (the text before it sits at its stops, from the left edge). + last_tab: Option, } fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut paragraph: ParagraphData<'_, '_>) -> bool { @@ -667,7 +670,14 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin } } let position = &line.position; - let start_x = if justify { + // Right-aligned area type with a tab: the text before the last tab sits at its stops, from the + // left edge, and the text after it against the right edge, as Illustrator's own layout in a + // user's `.ai` sets a price list. Centred and justified lines are aligned as a whole. + let last_tab = (para.justify == Justify::Right && !justify && !paragraph.rtl && paragraph.regions.is_some()) + // A tab ending the line counts: its text sits at the left edge, the tab up to the right one. + .then(|| (line.index..line.position.end).rev().find(|&j| sg.get(j).is_some_and(|g| g.ch == '\t' && g.len > 0))) + .flatten(); + let start_x = if justify || last_tab.is_some() { line.ax0 - line.opt_l } else if paragraph.regions.is_none() { match align { @@ -682,7 +692,35 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin _ => 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, fit, start_x, after, kashidas, last_tab } +} + +/// The advance and leader of tab `glyph_index` of `line`, at pen position `x`: up to its stop +/// ([`tab_advance`], measured from `tab_origin` less `tab_shift`), or, for the last tab of +/// right-aligned area type ([`LineJustification::last_tab`]), up to where the rest of the line +/// ends on the right edge. The tab after a list marker keeps the width `prepend_marker` gave it +/// (`advance`). +fn tab_glyph<'p>( + paragraph: &'p ParagraphData<'_, '_>, + line: &LineGeometry, + justification: &LineJustification, + glyph_index: usize, + x: f64, + (tab_origin, tab_shift): (f64, f64), + advance: f64, +) -> (f64, &'p str) { + let sg = ¶graph.sg; + if !sg.get(glyph_index).is_some_and(|g| g.len > 0) { + return (advance, ""); + } + let (advance, leader) = tab_advance(¶graph.para.tabs, tab_origin, x - tab_shift, sg.get(glyph_index + 1..line.trimmed).unwrap_or_default()); + if justification.last_tab != Some(glyph_index) { + return (advance, leader); + } + // The width of what follows up to the line's end (nothing, when the tab ends the line). + let after = sg.get(glyph_index + 1..line.trimmed).unwrap_or_default(); + let rest = if after.is_empty() { 0.0 } else { after.iter().map(|g| g.adv).sum::() - line.end_trim }; + ((line.ax1 + line.opt_r - rest - x).max(0.0), leader) } fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData<'_, '_>, line: &LineGeometry, justification: &LineJustification) { @@ -724,10 +762,7 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData let mut scale = 1.0; let mut leader = ""; if glyph.ch == '\t' { - // The tab after a list marker keeps the width `prepend_marker` gave it. - if glyph.len > 0 { - (advance, leader) = tab_advance(¶.tabs, tab_origin, x - tab_shift, &sg[glyph_index + 1..trimmed.max(glyph_index + 1)]); - } + (advance, leader) = tab_glyph(paragraph, line, justification, glyph_index, x, (tab_origin, tab_shift), advance); } else if glyph_index < body { let flex = flow_line_flex(glyph_index, ¶graph.flex, &line.held, &line.hung); if is_word_space(glyph) { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index e7526849e..a08ac54f6 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -664,12 +664,56 @@ fn a_leading_tab_takes_its_width_in_the_line_and_the_justification() { } } +/// Right-aligned area type with tabs: the text before the last tab sits at its stops, measured from +/// the frame's left edge, and the text after the last tab against the right edge, as Illustrator's +/// own layout in a user's `.ai` (used locally, not committed) sets a price list: "Name950 +/// Ft1 750 Ft" with a right stop at 269.29 and a left one at 272.13 puts the name at 0, the +/// first price ending at 269.29 and the second ending on the edge. +#[test] +fn right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge() { + use vectorcraft_doc::{TabAlign, TabStop}; + let stop = |position: f64, align: TabAlign| TabStop { position, align, leader: String::new(), align_on: '.' }; + let frame = Rect::new(0.0, 0.0, 336.0, 100.0); + let mut t = area("Name\t950 Ft\t1 750 Ft", style(13.0), frame, Justify::Right); + t.para.tabs = vec![stop(269.29, TabAlign::Right), stop(272.13, TabAlign::Left)]; + let lay = layout(db(), &t); + let glyph = |byte: usize| lay.glyphs.iter().find(|g| g.byte == byte).unwrap(); + let end = |byte: usize| glyph(byte).origin.x + glyph(byte).advance; + assert!(glyph(0).origin.x.abs() < 1e-6, "the name starts at the left edge: {}", glyph(0).origin.x); + assert!((end(10) - 269.29).abs() < 1e-6, "the first price ends on its right stop: {}", end(10)); + let last = t.plain_text().len() - 1; + assert!((end(last) - 336.0).abs() < 1e-6, "the last price ends on the edge: {}", end(last)); + // One tab: the text after it is against the edge, whatever its stop. + let mut one = area("Cseles Spritz\t2 350 Ft", style(13.0), frame, Justify::Right); + one.para.tabs = t.para.tabs.clone(); + let lay = layout(db(), &one); + let first = lay.glyphs.first().unwrap(); + let last = lay.glyphs.last().unwrap(); + assert!( + first.origin.x.abs() < 1e-6 && (last.origin.x + last.advance - 336.0).abs() < 1e-6, + "{} {}", + first.origin.x, + last.origin.x + last.advance + ); + // A tab ending the line (a heading "Sör" in the user's file): the text before it at the + // left edge, the tab to the right edge. + let lay = layout(db(), &area("Heading\t\nNext", style(13.0), frame, Justify::Right)); + assert!(lay.glyphs.first().unwrap().origin.x.abs() < 1e-6, "{}", lay.glyphs.first().unwrap().origin.x); + let tab = lay.glyphs.iter().find(|g| g.byte == 7).unwrap(); + assert!((tab.origin.x + tab.advance - 336.0).abs() < 1e-6, "the tab ends at {}", tab.origin.x + tab.advance); + // Without a tab, the line is right aligned as a whole. + let lay = layout(db(), &area("Name", style(13.0), frame, Justify::Right)); + assert!(lay.glyphs.first().unwrap().origin.x > 200.0); +} + #[test] fn a_centred_or_right_aligned_line_sets_its_tabs_as_if_it_started_at_the_left() { use vectorcraft_doc::{TabAlign, TabStop}; // "AB" with a stop at 100 pt is 100 pt plus B wide whatever the alignment: the whole line // moves, the gap between A and B stays 100 pt, and a right aligned line ends on the edge (#2457). - for justify in [Justify::Center, Justify::Right, Justify::JustifyCenter, Justify::JustifyRight] { + // (Right aligned area type sets the text after its last tab against the edge instead: see + // `right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`.) + for justify in [Justify::Center, Justify::JustifyCenter, Justify::JustifyRight] { let mut t = area("A\tB", style(12.0), Rect::new(0.0, 0.0, 200.0, 100.0), justify); t.para.tabs = vec![TabStop { position: 100.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }]; let lay = layout(db(), &t); diff --git a/docs/type-parity.md b/docs/type-parity.md index 6100f028a..5b0ca35a5 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (a soft return in justified type no longer keeps its line from being justified; Indian-language hyphenation review and license allowlist, #4947; Bulgarian among the languages, with its quote marks and the fonts' Bulgarian forms, #5945; previously minor: Show Snap to Glyph Options scrolls to the options, with a Snap to Glyph checkbox, #5273; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Indian-language hyphenation review and license allowlist, #4947; Bulgarian among the languages, with its quote marks and the fonts' Bulgarian forms, #5945; previously minor: Show Snap to Glyph Options scrolls to the options, with a Snap to Glyph checkbox, #5273; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -45,7 +45,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Hyphenation… and Justification… options | done | Eleven Indian languages use attributed patterns and preserve syllables in both composers (#4947). Hyphenation… (`ui.hyphenation`, `text.setFormat`) sets each paragraph's Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone (Single-line Composer only) and Hyphenate Capitalized Words, and paragraph styles carry them (#2310). Justification… (`ui.justification`, `text.setFormat`, #2755) sets Word Spacing, Letter Spacing and Glyph Scaling (Minimum, Desired, Maximum), Auto Leading and Single Word Justification; Desired applies to every line, justified lines and the Every-line Composer use the ranges. Vertical type isn't glyph scaled; type on a path doesn't use them. Evidence: `crates/text/src/tests/justify.rs`, `dialogs::justification::tests` | | Roman Hanging Punctuation | done | Paragraph panel menu › Roman Hanging Punctuation, `text.setFormat {romanHangingPunctuation}` (#2756): per paragraph, Optical Margin Alignment's punctuation and amounts without the letters; paragraph styles carry it. Evidence: `tests::roman_hanging_punctuation_hangs_punctuation_but_not_letters`, `typecmd::area_tests::roman_hanging_punctuation_is_set_per_paragraph_and_saved_only_when_on` | | Hyphenation… and Justification… options | done | Eleven Indian languages use attributed patterns and preserve syllables in both composers (#4947). Hyphenation… (`ui.hyphenation`, `text.setFormat`) sets each paragraph's Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone (Single-line Composer only) and Hyphenate Capitalized Words, and paragraph styles carry them (#2310). Justification… (`ui.justification`, `text.setFormat`, #2755) sets Word Spacing, Letter Spacing and Glyph Scaling (Minimum, Desired, Maximum), Auto Leading and Single Word Justification; Desired applies to every line, justified lines and the Every-line Composer use the ranges. Vertical type isn't glyph scaled; type on a path doesn't use them. Auto Leading also sets the Leading the Character panel shows for Auto type, the leading Alt+↑/↓ (`type.step`) steps from, per paragraph, and the lines Fill with Placeholder Text counts. Evidence: `crates/text/src/tests/justify.rs`, `dialogs::justification::tests`, `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading` | -| Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leaders fill the tab's gap, repeated on a grid so they line up from line to line (#1877); Ctrl-dragging a stop (Command on macOS) moves all the stops together | +| Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leaders fill the tab's gap, repeated on a grid so they line up from line to line (#1877); Ctrl-dragging a stop (Command on macOS) moves all the stops together; in right-aligned area type the text before a line's last tab sits at its stops, from the left edge, and the text after it against the right edge, as Illustrator's own layout in a user's `.ai` sets a price list (evidence: `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`; centred and justified lines are still aligned as a whole) | | Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` | | Spell check (Check Spelling, Auto Spell Check, custom dictionary) | missing | menu stubs; needs an openly licensed dictionary | | Smart Punctuation, Change Case, Find & Replace | done | | @@ -79,6 +79,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | Tabs: in right-aligned area type, the text before a line's last tab sits at its stops, measured from the left edge, and the text after it (none, when the tab ends the line) against the right edge; the line was aligned as a whole, which moved its start off the left edge. Illustrator's own layout in a user's `.ai` (used locally, not committed) sets its price lists so. Centred and justified lines keep their alignment as a whole. Scores and estimates unchanged. | | 2026-11-07 | minor | Soft return: in Justify Left, Center or Right, the line a soft return ends is justified as a wrapped line is; only the paragraph's last line keeps its alignment. Illustrator's own layout in a user's `.ai` (used locally, not committed) justifies those lines. Scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-06 | minor | Alt + numeric-keypad codes (`Alt+0176` → °) type their character at the caret on Windows, and `type.insert` inserts a character by `code` or `unicode` (#5108); scores and estimates unchanged | From 88bc2ac06adaf18d7141fe8fda1fe0236eb30f13 Mon Sep 17 00:00:00 2001 From: M Balazs PC Date: Sat, 7 Nov 2026 18:49:20 +0100 Subject: [PATCH 19/71] .ai/EPS import: type keeps its paragraphs' tab stops Key 30 of a paragraph's attributes holds its tab stops: each its position (key 0) and alignment (key 1: 0 left, 1 center, 2 right, 3 decimal). They were not read, so tabs fell on the default stops. At most 1,024 are read a paragraph and 2^20 copied into a story's paragraphs, past which the default stops apply with a warning. The fuzz test feeds hostile paragraph attributes: alignments, auto leading, hyphenation options and tab stops. --- crates/engine/tests/import_fuzz.rs | 56 +++++++++++ crates/eps/src/import/ate.rs | 134 ++++++++++++++++++++++++++- crates/eps/src/import/native/plan.rs | 6 +- docs/file-format-parity.md | 5 +- 4 files changed, 194 insertions(+), 7 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index f1a1f62c0..8d43bbdd2 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3015,6 +3015,50 @@ fn text_editing_of(document: &str, objects: &str) -> String { /// Area Type Options as an area frame's data may keep them: rows, columns, gutters and the first /// baseline, and keys besides, each with a value of any kind, in or out of range. +/// A paragraph's attributes as a text document might keep them: an alignment (key 0), auto leading +/// (7), hyphenation options (10 to 15) and tab stops (30) of any value or type, the stops a list of +/// any length whose entries may be anything. +fn arb_paragraph_attributes() -> impl Strategy { + let values = prop::sample::select(vec![ + "0", + "1", + "2", + "3", + "6", + "7", + "-1", + "2.5", + "1e400", + "-1e400", + "NaN", + "inf", + "1e9", + "(x)", + "true", + "false", + "/nil", + "[ 1 ]", + "<< /0 1 >>", + ]); + let keys = prop::sample::select(vec!["0", "7", "10", "11", "12", "13", "14", "15"]); + let stop = (values.clone(), values.clone(), 0usize..3).prop_map(|(at, align, shape)| match shape { + 0 => format!("<< /0 {at} /1 {align} >>"), + 1 => format!("<< /1 {align} >>"), + _ => at.to_string(), + }); + let tabs = (prop::collection::vec(stop, 0..40), 0usize..4).prop_map(|(stops, shape)| match shape { + 0 => format!("/30 << /0 [ {} ] >>", stops.join(" ")), + 1 => "/30 << /0 7 >>".to_string(), + 2 => "/30 [ 1 2 ]".to_string(), + _ => String::new(), + }); + (prop::collection::vec((keys, values), 0..8), tabs).prop_map(|(kv, tabs)| { + let mut attrs: Vec = kv.iter().map(|(k, v)| format!("/{k} {v}")).collect(); + attrs.push(tabs); + attrs.join(" ") + }) +} + fn arb_area_options() -> impl Strategy { let keys = prop::sample::select(vec!["3", "4", "5", "7", "8", "10", "13", "14"]); let values = prop::sample::select(vec![ @@ -3192,6 +3236,18 @@ proptest! { survive(".ai text frames with hostile area options", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; } + /// The same text document with hostile paragraph attributes: an alignment, auto leading, + /// hyphenation options and tab stops of any value, type or size. + #[test] + fn text_frames_with_hostile_paragraph_attributes_never_panic(attrs in arb_paragraph_attributes()) { + let text = frames_document_text().replace("/5 << /0 2 /1 4 /2 6 >>", &format!("/5 << /1 4 /2 6 {attrs} >>")); + let editing = text_editing_of(&text, FRAME_OBJECTS); + let bytes = editing_eps(&editing); + survive("EPS text frames with hostile paragraph attributes", || vectorcraft_engine::cmd::fileio::load("x.eps", &bytes).ok().map(|l| l.doc))?; + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&editing)].concat(); + survive(".ai text frames with hostile paragraph attributes", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; + } + /// The same for vertical type and Tate-chu-yoko runs: orientations and baseline directions of /// any value, type or size. #[test] diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index f3061bb75..a6e0ade06 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -25,7 +25,8 @@ use std::collections::BTreeMap; use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ - CharStyle, HyphenationOptions, JustificationOptions, Justify, Node, NodeId, NodeKind, ParaDirection, ParaStyle, TextKind, TextObject, TextRun, + CharStyle, HyphenationOptions, JustificationOptions, Justify, Node, NodeId, NodeKind, ParaDirection, ParaStyle, TabAlign, TabStop, TextKind, + TextObject, TextRun, }; use vectorcraft_geom::{Affine, PathData, Point, Rect}; @@ -353,8 +354,23 @@ struct Para { /// After First, Before Last, Hyphen Limit, Hyphenation Zone (pt) and Hyphenate Capitalized /// Words. A value that can't be read, or is out of its range, keeps the default. hyphenation: HyphenationOptions, + /// Key 30, its list (key 0): the tab stops, each its position from the frame's left edge (key + /// 0) and alignment (key 1: 0 left, the default, 1 center, 2 right, 3 decimal, in the order of + /// the Tabs panel's buttons), sorted. A stop that can't be read is left out, and so are those + /// past the first [`MAX_TABS`]. Leaders aren't read. + tabs: Vec, } +/// Most tab stops read for one paragraph. +const MAX_TABS: usize = 1024; +/// Largest tab stop position read (in the text document's units), as `text.tabs.set` takes it. +const MAX_TAB_POSITION: f64 = 100_000.0; +/// Most tab stops a story's paragraphs take in all: a paragraph style's stops are copied into +/// every paragraph that has them. The paragraphs past it take the default stops, with a warning. +const MAX_STORY_TABS: usize = 1 << 20; +/// The warning when [`MAX_STORY_TABS`] leaves tab stops out. +pub(crate) const TABS_LEFT_OUT: &str = "tab stops past VectorCraft's limits were left out"; + /// The text of one story, in the units of the text document. #[derive(Debug)] pub(super) struct Story { @@ -375,6 +391,20 @@ pub(super) struct Story { /// Vertical type (Type › Type Orientation › Vertical): its lines are columns, from right to /// left. Its layout's lines run down from the anchor, each column further left. vertical: bool, + /// Tab stops past [`MAX_STORY_TABS`] were left out of its paragraphs. + tabs_left_out: std::cell::Cell, +} + +/// Spend `budget` on the tab stops `tabs`: when there aren't enough left, they're left out (the +/// default stops apply). Whether they were. +fn spend_tabs(tabs: &mut Vec, budget: &mut usize) -> bool { + if tabs.len() <= *budget { + *budget -= tabs.len(); + false + } else { + tabs.clear(); + true + } } #[derive(Clone, Debug)] @@ -572,7 +602,12 @@ impl Texts { if runs.is_empty() { runs.push((text.chars().count(), self.style(None))); } - Some(Story { text, anchor, lines, frames, paras, runs, path_spacing, vertical }) + let mut budget = MAX_STORY_TABS; + let mut tabs_left_out = false; + for (_, p) in &mut paras { + tabs_left_out |= spend_tabs(&mut p.tabs, &mut budget); + } + Some(Story { text, anchor, lines, frames, paras, runs, path_spacing, vertical, tabs_left_out: tabs_left_out.into() }) } /// A paragraph's attributes (`run`: its attributes, key 5, and the paragraph style it is based @@ -616,6 +651,27 @@ impl Texts { }, } }, + tabs: { + let list = pick("30").and_then(|t| t.get("0")).map(Val::list).unwrap_or_default(); + let mut tabs: Vec = list + .iter() + .take(MAX_TABS) + .filter_map(|t| { + let position = t.get("0").and_then(Val::num).filter(|p| (0.0..=MAX_TAB_POSITION).contains(p))?; + let align = match t.get("1").map(Val::num) { + None | Some(Some(0.0)) => TabAlign::Left, + Some(Some(1.0)) => TabAlign::Center, + Some(Some(2.0)) => TabAlign::Right, + Some(Some(3.0)) => TabAlign::Decimal, + _ => return None, + }; + Some(TabStop { position, align, leader: String::new(), align_on: '.' }) + }) + .collect(); + tabs.sort_by(|a, b| a.position.total_cmp(&b.position)); + tabs.dedup_by(|a, b| (a.position - b.position).abs() < 1e-6); + tabs + }, } } @@ -966,6 +1022,12 @@ impl Story { && vectorcraft_text::fit_source_lines(vectorcraft_text::FontDb::global(), t, self.lines.len()) } + /// Were tab stops past [`MAX_STORY_TABS`] left out of its paragraphs (so far: making its text + /// object counts them again)? + pub(super) fn tabs_left_out(&self) -> bool { + self.tabs_left_out.get() + } + /// Area type (`node`, the story's first frame as [`Self::node`] makes it with `template` and /// `to_doc`) whose first line the file lays out higher or lower than it is laid out here, as its /// page draws it: its first baseline is fixed where the file has it. The font's ascent below the @@ -1011,9 +1073,13 @@ impl Story { let mut runs = self.paras.iter(); let mut current = runs.next(); let mut left = current.map_or(usize::MAX, |r| r.0); + let mut budget = MAX_STORY_TABS; for p in text.split('\n') { - let para = current.map(|r| r.1.clone()).unwrap_or_default(); - out.push(para_style(¶, scale)); + let mut style = current.map_or_else(|| para_style(&Para::default(), scale), |r| para_style(&r.1, scale)); + if spend_tabs(&mut style.tabs, &mut budget) { + self.tabs_left_out.set(true); + } + out.push(style); // Each paragraph and its ending `\r` count. let mut used = p.chars().count() + 1; while used > 0 { @@ -1054,6 +1120,7 @@ fn para_style(p: &Para, scale: f64) -> ParaStyle { space_after: p.space_after * scale, hyphenate: p.hyphenate, hyphenation: HyphenationOptions { zone: p.hyphenation.zone * scale, ..p.hyphenation.clone() }, + tabs: p.tabs.iter().map(|t| TabStop { position: t.position * scale, ..t.clone() }).collect(), // A right-to-left paragraph lays its text out from the right whatever letters it has // (" 120.5 mm " reads "mm 120.5"); without the key, from its first strong letter. direction: p.rtl.then_some(ParaDirection::RightToLeft), @@ -1181,6 +1248,64 @@ mod tests { assert_eq!(own(b"/10 0 /11 99 /12 2.5 /13 -1 /14 -3 /15 1"), d); } + /// Key 30 holds the paragraph's tab stops: their positions (key 0), sorted, and alignments (key + /// 1: 0 left, 1 center, 2 right, 3 decimal), scaled as sizes are. A stop that can't be read is + /// left out. A user's price list keeps a right stop at 269.29 and a left one at 272.13. + #[test] + fn a_paragraph_keeps_its_tab_stops() { + let texts = Texts { + stories: vec![], + fonts: vec![], + defaults: Val::Dict(BTreeMap::new()), + para_defaults: Val::Dict(BTreeMap::new()), + char_sheets: vec![], + para_sheets: vec![], + frames: vec![], + }; + let tabs = |own: &[u8], scale: f64| { + para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), scale) + .tabs + .iter() + .map(|t| (t.position, t.align)) + .collect::>() + }; + assert_eq!(tabs(b"", 1.0), []); + assert_eq!( + tabs(b"/30 << /0 [ << /0 272.12598 /1 0 >> << /0 269.29135 /1 2 >> ] >>", 1.0), + [(269.29135, TabAlign::Right), (272.12598, TabAlign::Left)] + ); + assert_eq!( + tabs(b"/30 << /0 [ << /0 10 /1 1 >> << /0 20 /1 3 >> << /0 30 >> ] >>", 0.5), + [(5.0, TabAlign::Center), (10.0, TabAlign::Decimal), (15.0, TabAlign::Left)] + ); + // Unreadable: a position out of range or not a number, an unknown alignment. + assert_eq!( + tabs(b"/30 << /0 [ << /0 -1 >> << /0 1e9 >> << /0 /nil >> << /0 5 /1 7 >> << /0 6 /1 /x >> << /0 7 /1 2 >> ] >>", 1.0), + [(7.0, TabAlign::Right)] + ); + } + + /// A story's paragraphs take at most [`MAX_STORY_TABS`] tab stops in all (a paragraph style's + /// are copied into each paragraph): those past it take the default stops, and the story says so. + #[test] + fn a_story_s_tab_stops_are_bounded() { + let mut budget = 3; + let mut tabs = vec![TabStop { position: 1.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }; 2]; + assert!(!spend_tabs(&mut tabs, &mut budget) && tabs.len() == 2 && budget == 1); + assert!(spend_tabs(&mut tabs, &mut budget) && tabs.is_empty() && budget == 1); + // Paragraphs of a style with MAX_TABS stops each, more than the story takes. + let stop = TabStop { position: 1.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }; + let para = Para { tabs: vec![stop; MAX_TABS], ..Para::default() }; + let count = MAX_STORY_TABS / MAX_TABS + 2; + let s = story(&"a\r".repeat(count), &[0.0], ¶); + assert!(!s.tabs_left_out()); + let styles = s.paragraph_styles(&format!("{}a", "a\n".repeat(count - 1)), 1.0); + assert_eq!(styles.len(), count); + assert!(s.tabs_left_out()); + assert_eq!(styles.iter().map(|p| p.tabs.len()).sum::(), MAX_STORY_TABS); + assert!(styles.last().is_some_and(|p| p.tabs.is_empty())); + } + /// Where point type lies runs along its baseline: a label turned a quarter (as a vertical /// dimension's is) lies in a tall box, not a wide one. #[test] @@ -1418,6 +1543,7 @@ mod tests { )], path_spacing: 0.0, vertical: false, + tabs_left_out: false.into(), } } diff --git a/crates/eps/src/import/native/plan.rs b/crates/eps/src/import/native/plan.rs index 4e1e0a004..fdfbc9d7e 100644 --- a/crates/eps/src/import/native/plan.rs +++ b/crates/eps/src/import/native/plan.rs @@ -59,7 +59,7 @@ pub(super) fn make_slots( to_doc: Affine, warn: &mut Vec, ) -> Vec> { - let (mut taller, mut deeper) = (0, 0); + let (mut taller, mut deeper, mut tabs_left_out) = (0, 0, false); let made = infos .iter() .enumerate() @@ -67,6 +67,7 @@ pub(super) fn make_slots( let t = template?; let story = stories.get(k)?.as_ref()?; let mut node = story.node(NodeId(0), info.frame, t, to_doc)?; + tabs_left_out |= story.tabs_left_out(); let mut region = story.region(t, to_doc); // The first line where the file has it, in the text document's frame (that of a // copied object is its original's, as its lines are). @@ -97,6 +98,9 @@ pub(super) fn make_slots( "area type whose last lines the file sets in frames too short for them here got taller frames ({deeper}): those lines wouldn't show otherwise" )); } + if tabs_left_out { + warn.push(crate::import::ate::TABS_LEFT_OUT.into()); + } made } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 57ced6c12..189193336 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (`.ai` and EPS type from the text document keeps auto leading, hyphenation options and the file's last lines; EPS and `.ai` editing data: a transparency set inside a clipping group stays after its `Q`; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (`.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading, hyphenation options and the file's last lines; EPS and `.ai` editing data: a transparency set inside a clipping group stays after its `Q`; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -51,7 +51,7 @@ private editing data. VectorCraft: editing data of a `.ai`, EPS or PDF; other art opens in a group. Type from the text document keeps auto leading as auto, each line taking the paragraph's share (Auto Leading) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)), - and the paragraph's hyphenation options. Area type of one + and the paragraph's hyphenation options and tab stops. Area type of one frame whose last lines the file sets but which don't fit here (the fallback font, or a descent past the frame's bottom) gets a frame taller from its bottom, with a warning. RGB in the PDF page's `ICCBased` spaces keeps the file's numbers in a `.ai` (the `uncalibrated_rgb` @@ -217,6 +217,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | EPS and `.ai` type from the text document keeps its paragraphs' tab stops (positions and alignments; leaders aren't read), at most 1,024 a paragraph and 2^20 a story, past which the default stops apply with a warning. A user's `.ai` (used locally, not committed) whose price lists came in on the default stops now lays them out as its page has them (its type 0.8% from its page, from 1.5%; what differs is hyphenation in Hungarian and type in a font that isn't installed). Scores and estimates unchanged. | | 2026-11-07 | minor | EPS and `.ai` type from the text document: auto leading stays auto at the paragraph's share (it was the first two lines' distance for every line, so 10 pt lines under a 15 pt heading came in 18 pt apart), the paragraph's hyphenation options are read, and area type whose last lines the file sets in too short a frame for them here gets a taller frame. A user's `.ai` (used locally, not committed) whose type differed from its page by 6.6%, so that it opened from its PDF part, now opens from its layers (4.9%; type with tab stops in right-aligned paragraphs still differs, a separate issue) (#6137). Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: `Q` restores the painting state but not the transparency (`Xy`), which the writer only sets when the next object's differs from the last one written. A Screen glow followed by a clipping group set back to Normal no longer leaves the type after the group in Screen: two users' Illustrator 26.5 and 30 EPS posters (used locally, not committed) now show their gold heading and crescent, and both open from their layers (2.5% and 2.7% from the page before, under the limit after). Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: a blend whose spine was never edited, written with its keys and no spine part, comes in as a live blend along the straight lines between its keys' centres (in its record's order) instead of as its drawn steps. Checked on six users' Illustrator EPS files (used locally, not committed): every blend in four of them now comes in live, their renders unchanged; two still have blends the reader can't match to their drawn steps. Scores and estimates unchanged. | From c0341c3fffc1358ba54eb7e3ae13d1210de4ed4b Mon Sep 17 00:00:00 2001 From: M Balazs PC Date: Sat, 7 Nov 2026 19:10:21 +0100 Subject: [PATCH 20/71] Type: a paragraph ending in a soft return justifies the line the soft return ends A paragraph that ends in a soft return ends with the empty line after it, so the line the soft return ends is justified like any other; it was taken for the paragraph's last line and left aligned. --- crates/text/src/layout/flow.rs | 6 ++++-- crates/text/src/tests/typo.rs | 5 +++++ docs/roadmap.md | 3 ++- docs/target-app-parity.md | 5 +++-- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index f46c1c23a..42232f449 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -544,8 +544,10 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData let mut held = held; held.extend(index..paragraph.marker.min(body)); // A line a soft return ends is justified as a wrapped one is: only the paragraph's last line - // keeps the alignment of Justify Left, Center or Right. - let last_of_para = position.end >= n; + // keeps the alignment of Justify Left, Center or Right. A paragraph that ends in a soft return + // ends with the empty line after it (`run_paragraph_lines`). + let soft_return = position.end > index && sg.get(position.end - 1).is_some_and(|g| g.ch == '\u{2028}'); + let last_of_para = position.end >= n && !soft_return; LineGeometry { position, index, diff --git a/crates/text/src/tests/typo.rs b/crates/text/src/tests/typo.rs index ee629ba4a..9dd13ea09 100644 --- a/crates/text/src/tests/typo.rs +++ b/crates/text/src/tests/typo.rs @@ -1983,6 +1983,11 @@ fn soft_return_in_justified_text_spreads_like_a_wrapped_line() { let (l0, l1) = (&lay.lines[0], &lay.lines[1]); assert!((l0.x0 - frame.x0).abs() < 1e-6 && (l0.x1 - frame.x1).abs() < 1e-6, "{justify:?}: {l0:?}"); assert!(l1.x1 - l1.x0 < 200.0, "{justify:?}: the last line isn't justified: {l1:?}"); + // A soft return ending the paragraph: the empty line after it is the last one, so the line + // it ends is justified too. + let lay = layout(db(), &area("A line of a few words\u{2028}", style(12.0), frame, justify)); + let l0 = lay.lines.first().unwrap(); + assert!((l0.x0 - frame.x0).abs() < 1e-6 && (l0.x1 - frame.x1).abs() < 1e-6, "{justify:?}, trailing soft return: {l0:?}"); } // Aligned type stays aligned. let lay = layout(db(), &area(text, style(12.0), frame, Justify::Left)); diff --git a/docs/roadmap.md b/docs/roadmap.md index c4246c711..34f90f4c0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (M4/M7: `.ai`/EPS type from the text document lays its lines out as the file's page, #6137; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -36,6 +36,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps auto leading at the paragraph's share, the paragraph's hyphenation options and the last lines the file sets in a frame, and a line a soft return ends is justified as a wrapped line is (#6137); a user's `.ai` that opened from its PDF part now opens from its layers. Scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-05 | minor | Effect dialogs: a colour swatch beside a hex colour field follows typed values, and a double-click opens the Color Picker over the dialog, whose OK fills the field (#5365); scores and estimates unchanged. | | 2026-11-05 | minor | Selection Tools add-on: Select Linked, Embedded, Missing and Modified select the images and placed documents with that link status (Object › Plug-ins › Selection Tools and the Links panel menu; Shift adds), and Select by Link Status… lists them by name, status and format to pick from, optionally zooming to them; what is hidden, locked, on a template layer or outside the isolated group is left out and the status bar says how many (#5526, a VectorCraft addition); scores and estimates unchanged (estimated). | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0af2f0670..69b47b723 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (`.ai`/EPS type from the text document lays its lines out as the file's page, #6137; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -16,10 +16,11 @@ Ligature caret stops use a font's GDEF contour-point positions in horizontal and Creating a gradient from solid or empty paint uses the document's color model for its default stops, including drawing its vector with the Gradient tool. Explicit `keepModel: true` retains RGB defaults, and existing gradients keep their own colors. Scores and estimates are unchanged. -**Feature evidence last updated:** 2026-11-05. +**Feature evidence last updated:** 2026-11-07. The shared assessment status line and revision history are current upstream; this dated section records the submission's feature evidence and revisions without rescoring upstream or claiming a new combined build or runtime measurement. +- File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading at the paragraph's share of each line's largest type, the paragraph's hyphenation options, and the last lines the file sets in a frame; a line a soft return ends is justified as a wrapped line is. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`; scores and estimates unchanged - Current-main conflict reconciliation retains upstream and feature behavior; scores and estimates unchanged - Superseded 2026-10-20 behavior, retained as history: Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting uses the original target artboard and revision-scoped bounds caches (scores/hours unchanged) From d8893907318c61ae259cc1bbc5f5b527a37518f9 Mon Sep 17 00:00:00 2001 From: M Balazs PC Date: Sat, 7 Nov 2026 19:14:21 +0100 Subject: [PATCH 21/71] Tests: the right-aligned tab test uses its own example text --- crates/text/src/tests.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index a08ac54f6..636f71c22 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -666,25 +666,26 @@ fn a_leading_tab_takes_its_width_in_the_line_and_the_justification() { /// Right-aligned area type with tabs: the text before the last tab sits at its stops, measured from /// the frame's left edge, and the text after the last tab against the right edge, as Illustrator's -/// own layout in a user's `.ai` (used locally, not committed) sets a price list: "Name950 -/// Ft1 750 Ft" with a right stop at 269.29 and a left one at 272.13 puts the name at 0, the -/// first price ending at 269.29 and the second ending on the edge. +/// own layout in a user's `.ai` (used locally, not committed) sets a list of an item and two +/// prices: with a right stop at 269.29 and a left one at 272.13, the item starts at 0, the first +/// price ends at 269.29 and the second ends on the edge. #[test] fn right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge() { use vectorcraft_doc::{TabAlign, TabStop}; let stop = |position: f64, align: TabAlign| TabStop { position, align, leader: String::new(), align_on: '.' }; let frame = Rect::new(0.0, 0.0, 336.0, 100.0); - let mut t = area("Name\t950 Ft\t1 750 Ft", style(13.0), frame, Justify::Right); + let mut t = area("Item\t12.50\t24.00", style(13.0), frame, Justify::Right); t.para.tabs = vec![stop(269.29, TabAlign::Right), stop(272.13, TabAlign::Left)]; let lay = layout(db(), &t); let glyph = |byte: usize| lay.glyphs.iter().find(|g| g.byte == byte).unwrap(); let end = |byte: usize| glyph(byte).origin.x + glyph(byte).advance; - assert!(glyph(0).origin.x.abs() < 1e-6, "the name starts at the left edge: {}", glyph(0).origin.x); - assert!((end(10) - 269.29).abs() < 1e-6, "the first price ends on its right stop: {}", end(10)); + assert!(glyph(0).origin.x.abs() < 1e-6, "the item starts at the left edge: {}", glyph(0).origin.x); + let first_price_end = "Item\t12.50".len() - 1; + assert!((end(first_price_end) - 269.29).abs() < 1e-6, "the first price ends on its right stop: {}", end(first_price_end)); let last = t.plain_text().len() - 1; assert!((end(last) - 336.0).abs() < 1e-6, "the last price ends on the edge: {}", end(last)); // One tab: the text after it is against the edge, whatever its stop. - let mut one = area("Cseles Spritz\t2 350 Ft", style(13.0), frame, Justify::Right); + let mut one = area("Another item\t8.00", style(13.0), frame, Justify::Right); one.para.tabs = t.para.tabs.clone(); let lay = layout(db(), &one); let first = lay.glyphs.first().unwrap(); @@ -695,8 +696,8 @@ fn right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge() { first.origin.x, last.origin.x + last.advance ); - // A tab ending the line (a heading "Sör" in the user's file): the text before it at the - // left edge, the tab to the right edge. + // A tab ending the line (a heading in the user's file): the text before it at the left edge, + // the tab to the right edge. let lay = layout(db(), &area("Heading\t\nNext", style(13.0), frame, Justify::Right)); assert!(lay.glyphs.first().unwrap().origin.x.abs() < 1e-6, "{}", lay.glyphs.first().unwrap().origin.x); let tab = lay.glyphs.iter().find(|g| g.byte == 7).unwrap(); From d9fdd7a07238b9ce7deab18e1bf35e1e13c0d8cb Mon Sep 17 00:00:00 2001 From: M Balazs PC Date: Sat, 7 Nov 2026 19:25:01 +0100 Subject: [PATCH 22/71] Type: the last tab of right-aligned area type leaves room for the hyphen a hyphenated line ends with --- crates/text/src/layout/flow.rs | 6 ++++-- crates/text/src/tests.rs | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index e6b74b17e..de0de5bbc 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -719,9 +719,11 @@ fn tab_glyph<'p>( if justification.last_tab != Some(glyph_index) { return (advance, leader); } - // The width of what follows up to the line's end (nothing, when the tab ends the line). + // The width of what follows up to the line's end (nothing, when the tab ends the line), with + // the hyphen a hyphenated line ends with. let after = sg.get(glyph_index + 1..line.trimmed).unwrap_or_default(); - let rest = if after.is_empty() { 0.0 } else { after.iter().map(|g| g.adv).sum::() - line.end_trim }; + let hyphen = line.hyphen.as_ref().map_or(0.0, |g| g.adv); + let rest = if after.is_empty() { 0.0 } else { after.iter().map(|g| g.adv).sum::() - line.end_trim + hyphen }; ((line.ax1 + line.opt_r - rest - x).max(0.0), leader) } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 636f71c22..1958b4357 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -702,6 +702,16 @@ fn right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge() { assert!(lay.glyphs.first().unwrap().origin.x.abs() < 1e-6, "{}", lay.glyphs.first().unwrap().origin.x); let tab = lay.glyphs.iter().find(|g| g.byte == 7).unwrap(); assert!((tab.origin.x + tab.advance - 336.0).abs() < 1e-6, "the tab ends at {}", tab.origin.x + tab.advance); + // Text after the last tab that breaks with a hyphen: the hyphen ends on the edge too. + let text = "A\tinternationalization"; + let mut hyphenated = area(text, style(13.0), Rect::new(0.0, 0.0, 100.0, 200.0), Justify::Right); + hyphenated.para.hyphenate = true; + hyphenated.para.composer = vectorcraft_doc::Composer::SingleLine; + let lay = layout(db(), &hyphenated); + let line = &lay.lines[0]; + assert!(line.end > 2 && line.end < text.len(), "the first line breaks inside the word: {line:?}"); + let last = lay.glyphs.get(line.glyph_end - 1).unwrap(); + assert!((last.origin.x + last.advance - 100.0).abs() < 1e-6, "the hyphen ends at {}", last.origin.x + last.advance); // Without a tab, the line is right aligned as a whole. let lay = layout(db(), &area("Name", style(13.0), frame, Justify::Right)); assert!(lay.glyphs.first().unwrap().origin.x > 200.0); From 4c5ba22fdd6a78e57029e5d47f9be99918060b02 Mon Sep 17 00:00:00 2001 From: M Balazs PC Date: Sat, 7 Nov 2026 19:25:01 +0100 Subject: [PATCH 23/71] .ai/EPS import: a paragraph's tab stops past the limit are reported too --- crates/eps/src/import/ate.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index a6e0ade06..3acde345a 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -359,6 +359,8 @@ struct Para { /// the Tabs panel's buttons), sorted. A stop that can't be read is left out, and so are those /// past the first [`MAX_TABS`]. Leaders aren't read. tabs: Vec, + /// The paragraph has more than [`MAX_TABS`] stops: those past them were left out. + tabs_truncated: bool, } /// Most tab stops read for one paragraph. @@ -605,7 +607,7 @@ impl Texts { let mut budget = MAX_STORY_TABS; let mut tabs_left_out = false; for (_, p) in &mut paras { - tabs_left_out |= spend_tabs(&mut p.tabs, &mut budget); + tabs_left_out |= p.tabs_truncated | spend_tabs(&mut p.tabs, &mut budget); } Some(Story { text, anchor, lines, frames, paras, runs, path_spacing, vertical, tabs_left_out: tabs_left_out.into() }) } @@ -651,6 +653,7 @@ impl Texts { }, } }, + tabs_truncated: pick("30").and_then(|t| t.get("0")).map(Val::list).is_some_and(|list| list.len() > MAX_TABS), tabs: { let list = pick("30").and_then(|t| t.get("0")).map(Val::list).unwrap_or_default(); let mut tabs: Vec = list @@ -1278,6 +1281,15 @@ mod tests { tabs(b"/30 << /0 [ << /0 10 /1 1 >> << /0 20 /1 3 >> << /0 30 >> ] >>", 0.5), [(5.0, TabAlign::Center), (10.0, TabAlign::Decimal), (15.0, TabAlign::Left)] ); + // Past MAX_TABS stops, the rest are left out, and the paragraph says so (the story's warning). + let many = |n: usize| { + let stops: String = (0..n).map(|i| format!("<< /0 {i} >> ")).collect(); + texts.para(Some(&read(format!("/5 << /30 << /0 [ {stops}] >> >>").as_bytes()).unwrap())) + }; + let p = many(MAX_TABS + 6); + assert!(p.tabs_truncated && p.tabs.len() == MAX_TABS); + let p = many(MAX_TABS); + assert!(!p.tabs_truncated && p.tabs.len() == MAX_TABS); // Unreadable: a position out of range or not a number, an unknown alignment. assert_eq!( tabs(b"/30 << /0 [ << /0 -1 >> << /0 1e9 >> << /0 /nil >> << /0 5 /1 7 >> << /0 6 /1 /x >> << /0 7 /1 2 >> ] >>", 1.0), From b46d321c2b33526717d3a0e62120b765b0d75d80 Mon Sep 17 00:00:00 2001 From: M Balazs PC Date: Sat, 7 Nov 2026 19:25:01 +0100 Subject: [PATCH 24/71] .ai/EPS import: the parity assessment and roadmap record the tab stop fixes (#6147) --- docs/roadmap.md | 3 ++- docs/target-app-parity.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 34f90f4c0..aa1eef848 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (M4/M7: `.ai`/EPS type from the text document lays its lines out as the file's page, #6137; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out as the file's page, #6137; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -36,6 +36,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps its paragraphs' tab stops, and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops from the left edge (#6147). Scores and estimates unchanged. | | 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps auto leading at the paragraph's share, the paragraph's hyphenation options and the last lines the file sets in a frame, and a line a soft return ends is justified as a wrapped line is (#6137); a user's `.ai` that opened from its PDF part now opens from its layers. Scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-05 | minor | Effect dialogs: a colour swatch beside a hex colour field follows typed values, and a double-click opens the Color Picker over the dialog, whose OK fills the field (#5365); scores and estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 69b47b723..71ec6b15f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (`.ai`/EPS type from the text document lays its lines out as the file's page, #6137; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (`.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out as the file's page, #6137; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -20,6 +20,7 @@ Creating a gradient from solid or empty paint uses the document's color model fo The shared assessment status line and revision history are current upstream; this dated section records the submission's feature evidence and revisions without rescoring upstream or claiming a new combined build or runtime measurement. +- File formats and Type core (#6147): `.ai`/EPS type from the text document keeps its paragraphs' tab stops, and right-aligned area type sets the text before a line's last tab at its stops, from the left edge, and the text after it against the right edge. Evidence: `ate::tests::a_paragraph_keeps_its_tab_stops`, `ate::tests::a_story_s_tab_stops_are_bounded`, `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`, `import_fuzz::text_frames_with_hostile_paragraph_attributes_never_panic`; scores and estimates unchanged - File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading at the paragraph's share of each line's largest type, the paragraph's hyphenation options, and the last lines the file sets in a frame; a line a soft return ends is justified as a wrapped line is. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`; scores and estimates unchanged - Current-main conflict reconciliation retains upstream and feature behavior; scores and estimates unchanged - Superseded 2026-10-20 behavior, retained as history: Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting uses the original target artboard and revision-scoped bounds caches (scores/hours unchanged) From 1b364b955d4b6875a00fda013081343058fc4cfa Mon Sep 17 00:00:00 2001 From: M Balazs PC Date: Sat, 7 Nov 2026 19:25:43 +0100 Subject: [PATCH 25/71] .ai/EPS import: the parity assessment's evidence and revision history record the line layout fixes (#6137) --- docs/target-app-parity.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 69b47b723..10ff176d0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -533,6 +533,7 @@ since 2026-10-01. - **Canvas review follow-up:** Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Controlled-worker regression checks oversized width and height, high DPI and the render transform without allocating oversized images. Scores and estimates are unchanged. - **Cached canvas frames during viewport changes:** Cached screen textures use their own raster dimensions and pixels-per-point when reprojected. `canvas::tests::cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes` holds the worker and checks every UV corner against the cached render transform, including isolated viewport-only resize at zero/30-degree rotation, fractional raster dimensions, zoom/pan, changed rotation, monitor-DPI changes in both directions, Pixel Preview and replacement-frame delivery. The contributor rebuilt and confirmed the original resize issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. - **PDF editable import:** Place from bytes or a path with `link: false`, and `clipboard.importPdf` followed by Paste, preserve live type on the selected page. Place still honors `textAs: "outlines"`. Synthetic two-page regressions: `crates/engine/src/cmd/fileio/tests_pdffidelity.rs`. No change to the PDF reader's existing fallback rules. +- **`.ai`/EPS type laid out as the file's page (#6137):** type from the text document keeps auto leading as auto, each line taking the paragraph's Auto Leading share (key 7) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)); the paragraph's hyphenation options (keys 10 to 15) are read; area type of one frame whose last lines the file sets, but which don't fit here, gets a frame taller from its bottom by at most a line's descent (`vectorcraft_text::fit_source_lines`), with a warning; and a line a soft return ends is justified as a wrapped line is, a paragraph's last line keeping its alignment. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_auto_leading`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Scores unchanged. - **Selected Grid Repeat handles (#2691):** `tools/src/repeat_grid.rs` tests place the handles outside the grid at zoom 0.5, 1 and 4, preview rows, columns and spacing, leave no undo step for a drag that ends where it began, and give bounding-box handles priority; a headless canvas regression drags the row handle two tiles down in a zoomed view (3 → 5 rows, the source unchanged, one Undo back) and the column-spacing slider. Scores and hours unchanged. - **Selected Mirror Repeat handles:** a headless canvas regression creates the default mirror repeat and drags its Top bounding-box handle where the axis crosses it; the repeat grows vertically while its width stays fixed. Existing mirror-axis checks cover center movement and end-handle hit priority. The focused UI mirror tests pass. Scores and hours unchanged. - **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. @@ -1726,6 +1727,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | File formats and Type core: `.ai`/EPS type from the text document lays its lines out as the file's page: auto leading per line at the paragraph's share, the paragraph's hyphenation options, the last lines the file sets in a frame, and a line a soft return ends justified (#6137); evidence added; scores unchanged. | | 2026-11-06 | minor | Direct Selection: with Snap to Grid, a dragged straight segment lands its anchors on the grid (#3835); evidence added; scores unchanged. | | 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: the corners of each glyph's box are points a drawn point lands on, also on a curved side (`B`), and the box turns with rotated type, its edges then Angular Guides (#5112; [Snap to Glyph options](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/snap-to-glyph-options.html)); scores and estimates unchanged. | | 2026-11-06 | minor | Drop Shadow: a swatch beside Color opens the Color Picker over the dialog, as the other effect colour fields do (#5364); correction: the #5365 entry listed Drop Shadow, whose Color is drawn outside the shared grid and had no swatch; scores unchanged. | From fe3b9b3a4d79514bdcf6581812df2c3a63606a8e Mon Sep 17 00:00:00 2001 From: M Balazs PC Date: Sat, 7 Nov 2026 19:25:54 +0100 Subject: [PATCH 26/71] .ai/EPS import: the parity assessment's evidence and revision history record the tab stop fixes (#6147) --- docs/target-app-parity.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 926c140bc..adc3b60b1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -534,6 +534,7 @@ since 2026-10-01. - **Canvas review follow-up:** Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Controlled-worker regression checks oversized width and height, high DPI and the render transform without allocating oversized images. Scores and estimates are unchanged. - **Cached canvas frames during viewport changes:** Cached screen textures use their own raster dimensions and pixels-per-point when reprojected. `canvas::tests::cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes` holds the worker and checks every UV corner against the cached render transform, including isolated viewport-only resize at zero/30-degree rotation, fractional raster dimensions, zoom/pan, changed rotation, monitor-DPI changes in both directions, Pixel Preview and replacement-frame delivery. The contributor rebuilt and confirmed the original resize issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. - **PDF editable import:** Place from bytes or a path with `link: false`, and `clipboard.importPdf` followed by Paste, preserve live type on the selected page. Place still honors `textAs: "outlines"`. Synthetic two-page regressions: `crates/engine/src/cmd/fileio/tests_pdffidelity.rs`. No change to the PDF reader's existing fallback rules. +- **Tab stops in `.ai`/EPS type and right-aligned lines with tabs (#6147):** type from the text document keeps its paragraphs' tab stops (key 30: positions and left, center, right and decimal alignments; leaders aren't read), at most 1,024 a paragraph and 2^20 a story, past which the default stops apply with a warning. In right-aligned area type, the text before a line's last tab sits at its stops, measured from the left edge, and the text after it (none when the tab ends the line, with its hyphen when the line is hyphenated) against the right edge; centred and justified lines, point type and right-to-left paragraphs are aligned as a whole as before. Evidence: `ate::tests::a_paragraph_keeps_its_tab_stops`, `ate::tests::a_story_s_tab_stops_are_bounded`, `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`, `import_fuzz::text_frames_with_hostile_paragraph_attributes_never_panic`. Scores unchanged. - **`.ai`/EPS type laid out as the file's page (#6137):** type from the text document keeps auto leading as auto, each line taking the paragraph's Auto Leading share (key 7) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)); the paragraph's hyphenation options (keys 10 to 15) are read; area type of one frame whose last lines the file sets, but which don't fit here, gets a frame taller from its bottom by at most a line's descent (`vectorcraft_text::fit_source_lines`), with a warning; and a line a soft return ends is justified as a wrapped line is, a paragraph's last line keeping its alignment. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_auto_leading`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Scores unchanged. - **Selected Grid Repeat handles (#2691):** `tools/src/repeat_grid.rs` tests place the handles outside the grid at zoom 0.5, 1 and 4, preview rows, columns and spacing, leave no undo step for a drag that ends where it began, and give bounding-box handles priority; a headless canvas regression drags the row handle two tiles down in a zoomed view (3 → 5 rows, the source unchanged, one Undo back) and the column-spacing slider. Scores and hours unchanged. - **Selected Mirror Repeat handles:** a headless canvas regression creates the default mirror repeat and drags its Top bounding-box handle where the axis crosses it; the repeat grows vertically while its width stays fixed. Existing mirror-axis checks cover center movement and end-handle hit priority. The focused UI mirror tests pass. Scores and hours unchanged. @@ -1728,6 +1729,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | File formats and Type core: `.ai`/EPS type keeps its paragraphs' tab stops, and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); evidence added; scores unchanged. | | 2026-11-07 | minor | File formats and Type core: `.ai`/EPS type from the text document lays its lines out as the file's page: auto leading per line at the paragraph's share, the paragraph's hyphenation options, the last lines the file sets in a frame, and a line a soft return ends justified (#6137); evidence added; scores unchanged. | | 2026-11-06 | minor | Direct Selection: with Snap to Grid, a dragged straight segment lands its anchors on the grid (#3835); evidence added; scores unchanged. | | 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: the corners of each glyph's box are points a drawn point lands on, also on a curved side (`B`), and the box turns with rotated type, its edges then Angular Guides (#5112; [Snap to Glyph options](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/snap-to-glyph-options.html)); scores and estimates unchanged. | From 66be035055a00f2b222e850198c4c88f2a81adff Mon Sep 17 00:00:00 2001 From: M Balazs PC Date: Sat, 7 Nov 2026 19:30:49 +0100 Subject: [PATCH 27/71] .ai/EPS import: the progress docs state the limits of the line layout fixes and note them on M4 and M7 (#6137) --- docs/roadmap.md | 12 ++++++------ docs/target-app-parity.md | 9 +++++---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 34f90f4c0..f0d369178 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (M4/M7: `.ai`/EPS type from the text document lays its lines out as the file's page, #6137; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -36,7 +36,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | Date | Change | Summary | |---|---|---| -| 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps auto leading at the paragraph's share, the paragraph's hyphenation options and the last lines the file sets in a frame, and a line a soft return ends is justified as a wrapped line is (#6137); a user's `.ai` that opened from its PDF part now opens from its layers. Scores and estimates unchanged. | +| 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps auto leading at the paragraph's share, the paragraph's hyphenation options and the last lines the file sets in a frame, and a line a soft return ends is justified as a wrapped line is (#6137); a user's `.ai` that opened from its PDF part now opens from its layers. Limits: frames grow only for area type of one frame, horizontal, by at most a line's descent; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-05 | minor | Effect dialogs: a colour swatch beside a hex colour field follows typed values, and a double-click opens the Color Picker over the dialog, whose OK fills the field (#5365); scores and estimates unchanged. | | 2026-11-05 | minor | Selection Tools add-on: Select Linked, Embedded, Missing and Modified select the images and placed documents with that link status (Object › Plug-ins › Selection Tools and the Links panel menu; Shift adds), and Select by Link Status… lists them by name, status and format to pick from, optionally zooming to them; what is hidden, locked, on a template layer or outside the isolated group is left out and the status bar says how many (#5526, a VectorCraft addition); scores and estimates unchanged (estimated). | @@ -124,13 +124,13 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | | M2 | Drawing tools + smart guides | ✅ mostly done (Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending) | 12–18 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options and the last lines the file sets in one area frame (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default | 4–8 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, a generated Basic Shapes library, User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, four generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 10ff176d0..2f752ebdb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (`.ai`/EPS type from the text document lays its lines out as the file's page, #6137; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (`.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -20,7 +20,7 @@ Creating a gradient from solid or empty paint uses the document's color model fo The shared assessment status line and revision history are current upstream; this dated section records the submission's feature evidence and revisions without rescoring upstream or claiming a new combined build or runtime measurement. -- File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading at the paragraph's share of each line's largest type, the paragraph's hyphenation options, and the last lines the file sets in a frame; a line a soft return ends is justified as a wrapped line is. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`; scores and estimates unchanged +- File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading at the paragraph's share of each line's largest type, the paragraph's hyphenation options, and the last lines the file sets in a frame; a line a soft return ends is justified as a wrapped line is. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Limits: frames grow only for area type of one frame, horizontal, by at most a line's descent; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently; scores and estimates unchanged - Current-main conflict reconciliation retains upstream and feature behavior; scores and estimates unchanged - Superseded 2026-10-20 behavior, retained as history: Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting uses the original target artboard and revision-scoped bounds caches (scores/hours unchanged) @@ -32,6 +32,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options and the last lines the file sets in one area frame, and a line a soft return ends is justified; evidence added. Limits: frames grow only for area type of one frame, horizontal, by at most a line's descent; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | | 2026-11-07 | minor | The canvas keeps selection outlines, anchors and handles between frames, so an idle frame with a large selection redraws them as one mesh; scores and estimates unchanged | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-06 | minor | Stroke gradients: an arrowhead whose tip sits on the end of a long straight leg is painted by the gradient to the corners of its base. The slices that reach across a head now run to the end of its reach instead of narrowing towards the next corner (#5964). Test: `stroke::tests_gradient::slices_cover_an_arrowhead_on_a_long_straight_leg`. Scores and estimates unchanged. | @@ -533,7 +534,7 @@ since 2026-10-01. - **Canvas review follow-up:** Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Controlled-worker regression checks oversized width and height, high DPI and the render transform without allocating oversized images. Scores and estimates are unchanged. - **Cached canvas frames during viewport changes:** Cached screen textures use their own raster dimensions and pixels-per-point when reprojected. `canvas::tests::cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes` holds the worker and checks every UV corner against the cached render transform, including isolated viewport-only resize at zero/30-degree rotation, fractional raster dimensions, zoom/pan, changed rotation, monitor-DPI changes in both directions, Pixel Preview and replacement-frame delivery. The contributor rebuilt and confirmed the original resize issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. - **PDF editable import:** Place from bytes or a path with `link: false`, and `clipboard.importPdf` followed by Paste, preserve live type on the selected page. Place still honors `textAs: "outlines"`. Synthetic two-page regressions: `crates/engine/src/cmd/fileio/tests_pdffidelity.rs`. No change to the PDF reader's existing fallback rules. -- **`.ai`/EPS type laid out as the file's page (#6137):** type from the text document keeps auto leading as auto, each line taking the paragraph's Auto Leading share (key 7) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)); the paragraph's hyphenation options (keys 10 to 15) are read; area type of one frame whose last lines the file sets, but which don't fit here, gets a frame taller from its bottom by at most a line's descent (`vectorcraft_text::fit_source_lines`), with a warning; and a line a soft return ends is justified as a wrapped line is, a paragraph's last line keeping its alignment. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_auto_leading`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Scores unchanged. +- **`.ai`/EPS type laid out closer to the file's page (#6137):** type from the text document keeps auto leading as auto, each line taking the paragraph's Auto Leading share (key 7) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)); the paragraph's hyphenation options (keys 10 to 15) are read; area type of one frame whose last lines the file sets, but which don't fit here, gets a frame taller from its bottom by at most a line's descent (`vectorcraft_text::fit_source_lines`), with a warning; and a line a soft return ends is justified as a wrapped line is, a paragraph's last line keeping its alignment. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_auto_leading`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Limits: frames grow only for area type of one frame, horizontal, by at most a line's descent; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores unchanged. - **Selected Grid Repeat handles (#2691):** `tools/src/repeat_grid.rs` tests place the handles outside the grid at zoom 0.5, 1 and 4, preview rows, columns and spacing, leave no undo step for a drag that ends where it began, and give bounding-box handles priority; a headless canvas regression drags the row handle two tiles down in a zoomed view (3 → 5 rows, the source unchanged, one Undo back) and the column-spacing slider. Scores and hours unchanged. - **Selected Mirror Repeat handles:** a headless canvas regression creates the default mirror repeat and drags its Top bounding-box handle where the axis crosses it; the repeat grows vertically while its width stays fixed. Existing mirror-axis checks cover center movement and end-handle hit priority. The focused UI mirror tests pass. Scores and hours unchanged. - **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. @@ -1727,7 +1728,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| -| 2026-11-07 | minor | File formats and Type core: `.ai`/EPS type from the text document lays its lines out as the file's page: auto leading per line at the paragraph's share, the paragraph's hyphenation options, the last lines the file sets in a frame, and a line a soft return ends justified (#6137); evidence added; scores unchanged. | +| 2026-11-07 | minor | File formats and Type core: `.ai`/EPS type from the text document lays its lines out closer to the file's page: auto leading per line at the paragraph's share, the paragraph's hyphenation options, the last lines the file sets in a frame, and a line a soft return ends justified (#6137); evidence added; scores unchanged. | | 2026-11-06 | minor | Direct Selection: with Snap to Grid, a dragged straight segment lands its anchors on the grid (#3835); evidence added; scores unchanged. | | 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: the corners of each glyph's box are points a drawn point lands on, also on a curved side (`B`), and the box turns with rotated type, its edges then Angular Guides (#5112; [Snap to Glyph options](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/snap-to-glyph-options.html)); scores and estimates unchanged. | | 2026-11-06 | minor | Drop Shadow: a swatch beside Color opens the Color Picker over the dialog, as the other effect colour fields do (#5364); correction: the #5365 entry listed Drop Shadow, whose Color is drawn outside the shared grid and had no swatch; scores unchanged. | From 4bfd7a207d20820700a065912078e921fbb65786 Mon Sep 17 00:00:00 2001 From: M Balazs PC Date: Sat, 7 Nov 2026 19:30:49 +0100 Subject: [PATCH 28/71] Docs: the functions the line layout fixes touch say what they do --- crates/eps/src/import/ate.rs | 4 ++++ crates/text/src/layout/flow.rs | 3 +++ 2 files changed, 7 insertions(+) diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index f3061bb75..fc78f6ee2 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -1034,6 +1034,8 @@ impl Story { } } +/// The paragraph style of paragraph attributes `p`, its lengths (indents, spacing, the +/// hyphenation zone) scaled by `scale`, as sizes are. fn para_style(p: &Para, scale: f64) -> ParaStyle { ParaStyle { justify: match p.justify { @@ -1066,6 +1068,8 @@ fn para_style(p: &Para, scale: f64) -> ParaStyle { } } +/// The character style of character attributes `s`: its font from the fonts the app has, its +/// lengths (size, leading, baseline shift, stroke width) scaled by `scale`. fn char_style(s: &Style, scale: f64) -> CharStyle { let (family, style) = s.font.as_deref().map_or_else(|| (CharStyle::default().font_family, "Regular".to_string()), crate::family_style); let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family); diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 42232f449..2ed8e3365 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -436,6 +436,9 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut true } +/// The geometry of the line of `paragraph` from glyph `index`, placed at `position`: where it +/// ends with its trailing spaces trimmed, its hyphen, its room and margins, the glyphs that hang or +/// stay out of justification, and whether it is the paragraph's last line. fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData<'_, '_>, position: LinePosition, index: usize) -> LineGeometry { let para = paragraph.para; let sg = ¶graph.sg; From 2beae3e7b47122407cfee8f88a5a4333fcd11e99 Mon Sep 17 00:00:00 2001 From: M Balazs PC Date: Sat, 7 Nov 2026 19:31:52 +0100 Subject: [PATCH 29/71] .ai/EPS import: the tab stop fixes are noted on M4 and M7 and in the feature revisions (#6147) --- docs/roadmap.md | 8 ++++---- docs/target-app-parity.md | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 96a0895ee..0e869cea2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -125,13 +125,13 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | | M2 | Drawing tools + smart guides | ✅ mostly done (Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending) | 12–18 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options and the last lines the file sets in one area frame (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options and the last lines the file sets in one area frame (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default | 4–8 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, a generated Basic Shapes library, User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, four generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 03a229d6c..a4f60a416 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -33,6 +33,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | File formats and Type core (#6147): `.ai`/EPS type keeps its paragraphs' tab stops (positions and alignments; leaders aren't read; at most 1,024 a paragraph and 2^20 a story), and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops; centred and justified lines are aligned as a whole as before; evidence added. Scores and estimates unchanged. | | 2026-11-07 | minor | File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options and the last lines the file sets in one area frame, and a line a soft return ends is justified; evidence added. Limits: frames grow only for area type of one frame, horizontal, by at most a line's descent; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | | 2026-11-07 | minor | The canvas keeps selection outlines, anchors and handles between frames, so an idle frame with a large selection redraws them as one mesh; scores and estimates unchanged | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | From 8561ef351cb5411f04cc5aecff5e35edc68903c9 Mon Sep 17 00:00:00 2001 From: M Balazs PC Date: Sat, 7 Nov 2026 19:34:47 +0100 Subject: [PATCH 30/71] Docs: the line layout functions the tab fix touches say what they do --- crates/text/src/layout/flow.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 852a9a272..d0a1f9962 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -601,6 +601,9 @@ fn flow_line_flex(glyph: usize, flex: &[Flex], held: &[usize], hung: &[usize]) - value } +/// How `line` of `paragraph` is aligned: where it starts, what justification adds to its spaces, +/// gaps and glyphs, its kashidas, and, in right-aligned area type, its last tab, which takes the +/// text after it to the right edge. fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &LineGeometry) -> LineJustification { let para = paragraph.para; let sg = ¶graph.sg; @@ -730,6 +733,9 @@ fn tab_glyph<'p>( ((line.ax1 + line.opt_r - rest - x).max(0.0), leader) } +/// Lay out `line` of `paragraph` as `justification` aligns it: each glyph placed along the +/// baseline (tabs as [`tab_glyph`] sets them), its hyphen, and the line's record, then the pen +/// moved on past it. fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData<'_, '_>, line: &LineGeometry, justification: &LineJustification) { let para = paragraph.para; let sg = ¶graph.sg; From 61afca297a8c343bde8e21f012e324aca1affc82 Mon Sep 17 00:00:00 2001 From: M Balazs PC Date: Sat, 7 Nov 2026 19:42:33 +0100 Subject: [PATCH 31/71] .ai/EPS import: the progress docs say frame fitting may show more of a frame's last lines, not that it always does (#6137) --- docs/file-format-parity.md | 4 ++-- docs/roadmap.md | 4 ++-- docs/target-app-parity.md | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 57ced6c12..e12eb901d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -53,7 +53,7 @@ private editing data. VectorCraft: (Auto Leading) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)), and the paragraph's hyphenation options. Area type of one frame whose last lines the file sets but which don't fit here (the fallback font, or a descent - past the frame's bottom) gets a frame taller from its bottom, with a warning. + past the frame's bottom) may get a frame taller from its bottom, by at most a line's descent and only when that shows more of those lines, with a warning when it does. RGB in the PDF page's `ICCBased` spaces keeps the file's numbers in a `.ai` (the `uncalibrated_rgb` import option, on for editor documents, off for PDFs), so `.ai` files tagged with a non-sRGB RGB profile open with different, unconverted RGB colours than before. @@ -217,7 +217,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| -| 2026-11-07 | minor | EPS and `.ai` type from the text document: auto leading stays auto at the paragraph's share (it was the first two lines' distance for every line, so 10 pt lines under a 15 pt heading came in 18 pt apart), the paragraph's hyphenation options are read, and area type whose last lines the file sets in too short a frame for them here gets a taller frame. A user's `.ai` (used locally, not committed) whose type differed from its page by 6.6%, so that it opened from its PDF part, now opens from its layers (4.9%; type with tab stops in right-aligned paragraphs still differs, a separate issue) (#6137). Scores and estimates unchanged. | +| 2026-11-07 | minor | EPS and `.ai` type from the text document: auto leading stays auto at the paragraph's share (it was the first two lines' distance for every line, so 10 pt lines under a 15 pt heading came in 18 pt apart), the paragraph's hyphenation options are read, and area type of one frame whose last lines the file sets in too short a frame for them here may get a frame taller by at most a line's descent, when that shows more of them. A user's `.ai` (used locally, not committed) whose type differed from its page by 6.6%, so that it opened from its PDF part, now opens from its layers (4.9%; type with tab stops in right-aligned paragraphs still differs, a separate issue) (#6137). Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: `Q` restores the painting state but not the transparency (`Xy`), which the writer only sets when the next object's differs from the last one written. A Screen glow followed by a clipping group set back to Normal no longer leaves the type after the group in Screen: two users' Illustrator 26.5 and 30 EPS posters (used locally, not committed) now show their gold heading and crescent, and both open from their layers (2.5% and 2.7% from the page before, under the limit after). Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: a blend whose spine was never edited, written with its keys and no spine part, comes in as a live blend along the straight lines between its keys' centres (in its record's order) instead of as its drawn steps. Checked on six users' Illustrator EPS files (used locally, not committed): every blend in four of them now comes in live, their renders unchanged; two still have blends the reader can't match to their drawn steps. Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: the art styles are found before the art is read, so objects in symbols defined ahead of `%AI9_BeginArtStyles` (as Illustrator 24 writes it) come in as themselves with their style instead of their drawn look. Checked on two users' EPS files (used locally, not committed): one's Symbols panel gains live Feather and Round Corners, its drawn-look count drops from 5 to 2, and both render unchanged; own-fixture test in `import::ai::tests`; scores and hours unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index f0d369178..795c06243 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -36,7 +36,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | Date | Change | Summary | |---|---|---| -| 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps auto leading at the paragraph's share, the paragraph's hyphenation options and the last lines the file sets in a frame, and a line a soft return ends is justified as a wrapped line is (#6137); a user's `.ai` that opened from its PDF part now opens from its layers. Limits: frames grow only for area type of one frame, horizontal, by at most a line's descent; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | +| 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps auto leading at the paragraph's share, the paragraph's hyphenation options and may show more of the last lines the file sets in an area frame of its own, when growing the frame by at most a line's descent shows them, and a line a soft return ends is justified as a wrapped line is (#6137); a user's `.ai` that opened from its PDF part now opens from its layers. Limits: frames grow only for area type of one frame, horizontal, by at most a line's descent; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-05 | minor | Effect dialogs: a colour swatch beside a hex colour field follows typed values, and a double-click opens the Color Picker over the dialog, whose OK fills the field (#5365); scores and estimates unchanged. | | 2026-11-05 | minor | Selection Tools add-on: Select Linked, Embedded, Missing and Modified select the images and placed documents with that link status (Object › Plug-ins › Selection Tools and the Links panel menu; Shift adds), and Select by Link Status… lists them by name, status and format to pick from, optionally zooming to them; what is hidden, locked, on a template layer or outside the isolated group is left out and the status bar says how many (#5526, a VectorCraft addition); scores and estimates unchanged (estimated). | @@ -124,7 +124,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | | M2 | Drawing tools + smart guides | ✅ mostly done (Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending) | 12–18 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options and the last lines the file sets in one area frame (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by at most a line's descent shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default | 4–8 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2f752ebdb..3c78dd43f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -20,7 +20,7 @@ Creating a gradient from solid or empty paint uses the document's color model fo The shared assessment status line and revision history are current upstream; this dated section records the submission's feature evidence and revisions without rescoring upstream or claiming a new combined build or runtime measurement. -- File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading at the paragraph's share of each line's largest type, the paragraph's hyphenation options, and the last lines the file sets in a frame; a line a soft return ends is justified as a wrapped line is. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Limits: frames grow only for area type of one frame, horizontal, by at most a line's descent; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently; scores and estimates unchanged +- File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading at the paragraph's share of each line's largest type, the paragraph's hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by at most a line's descent shows them; a line a soft return ends is justified as a wrapped line is. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Limits: frames grow only for area type of one frame, horizontal, by at most a line's descent; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently; scores and estimates unchanged - Current-main conflict reconciliation retains upstream and feature behavior; scores and estimates unchanged - Superseded 2026-10-20 behavior, retained as history: Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting uses the original target artboard and revision-scoped bounds caches (scores/hours unchanged) @@ -32,7 +32,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| -| 2026-11-07 | minor | File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options and the last lines the file sets in one area frame, and a line a soft return ends is justified; evidence added. Limits: frames grow only for area type of one frame, horizontal, by at most a line's descent; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | +| 2026-11-07 | minor | File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, may show more of the last lines the file sets in an area frame of its own, when growing the frame by at most a line's descent shows them, and a line a soft return ends is justified; evidence added. Limits: frames grow only for area type of one frame, horizontal, by at most a line's descent; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | | 2026-11-07 | minor | The canvas keeps selection outlines, anchors and handles between frames, so an idle frame with a large selection redraws them as one mesh; scores and estimates unchanged | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-06 | minor | Stroke gradients: an arrowhead whose tip sits on the end of a long straight leg is painted by the gradient to the corners of its base. The slices that reach across a head now run to the end of its reach instead of narrowing towards the next corner (#5964). Test: `stroke::tests_gradient::slices_cover_an_arrowhead_on_a_long_straight_leg`. Scores and estimates unchanged. | @@ -534,7 +534,7 @@ since 2026-10-01. - **Canvas review follow-up:** Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Controlled-worker regression checks oversized width and height, high DPI and the render transform without allocating oversized images. Scores and estimates are unchanged. - **Cached canvas frames during viewport changes:** Cached screen textures use their own raster dimensions and pixels-per-point when reprojected. `canvas::tests::cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes` holds the worker and checks every UV corner against the cached render transform, including isolated viewport-only resize at zero/30-degree rotation, fractional raster dimensions, zoom/pan, changed rotation, monitor-DPI changes in both directions, Pixel Preview and replacement-frame delivery. The contributor rebuilt and confirmed the original resize issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. - **PDF editable import:** Place from bytes or a path with `link: false`, and `clipboard.importPdf` followed by Paste, preserve live type on the selected page. Place still honors `textAs: "outlines"`. Synthetic two-page regressions: `crates/engine/src/cmd/fileio/tests_pdffidelity.rs`. No change to the PDF reader's existing fallback rules. -- **`.ai`/EPS type laid out closer to the file's page (#6137):** type from the text document keeps auto leading as auto, each line taking the paragraph's Auto Leading share (key 7) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)); the paragraph's hyphenation options (keys 10 to 15) are read; area type of one frame whose last lines the file sets, but which don't fit here, gets a frame taller from its bottom by at most a line's descent (`vectorcraft_text::fit_source_lines`), with a warning; and a line a soft return ends is justified as a wrapped line is, a paragraph's last line keeping its alignment. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_auto_leading`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Limits: frames grow only for area type of one frame, horizontal, by at most a line's descent; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores unchanged. +- **`.ai`/EPS type laid out closer to the file's page (#6137):** type from the text document keeps auto leading as auto, each line taking the paragraph's Auto Leading share (key 7) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)); the paragraph's hyphenation options (keys 10 to 15) are read; area type of one frame whose last lines the file sets, but which don't fit here, may get a frame taller from its bottom, by at most a line's descent and only when that shows more of those lines and no more than the file has (`vectorcraft_text::fit_source_lines`), with a warning when it does: the frame can stay as it is, or show only some of them; and a line a soft return ends is justified as a wrapped line is, a paragraph's last line keeping its alignment. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_auto_leading`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Limits: frames grow only for area type of one frame, horizontal, by at most a line's descent; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores unchanged. - **Selected Grid Repeat handles (#2691):** `tools/src/repeat_grid.rs` tests place the handles outside the grid at zoom 0.5, 1 and 4, preview rows, columns and spacing, leave no undo step for a drag that ends where it began, and give bounding-box handles priority; a headless canvas regression drags the row handle two tiles down in a zoomed view (3 → 5 rows, the source unchanged, one Undo back) and the column-spacing slider. Scores and hours unchanged. - **Selected Mirror Repeat handles:** a headless canvas regression creates the default mirror repeat and drags its Top bounding-box handle where the axis crosses it; the repeat grows vertically while its width stays fixed. Existing mirror-axis checks cover center movement and end-handle hit priority. The focused UI mirror tests pass. Scores and hours unchanged. - **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. @@ -1728,7 +1728,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| -| 2026-11-07 | minor | File formats and Type core: `.ai`/EPS type from the text document lays its lines out closer to the file's page: auto leading per line at the paragraph's share, the paragraph's hyphenation options, the last lines the file sets in a frame, and a line a soft return ends justified (#6137); evidence added; scores unchanged. | +| 2026-11-07 | minor | File formats and Type core: `.ai`/EPS type from the text document lays its lines out closer to the file's page: auto leading per line at the paragraph's share, the paragraph's hyphenation options, possibly more of the last lines the file sets in an area frame of its own, and a line a soft return ends justified (#6137); evidence added; scores unchanged. | | 2026-11-06 | minor | Direct Selection: with Snap to Grid, a dragged straight segment lands its anchors on the grid (#3835); evidence added; scores unchanged. | | 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: the corners of each glyph's box are points a drawn point lands on, also on a curved side (`B`), and the box turns with rotated type, its edges then Angular Guides (#5112; [Snap to Glyph options](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/snap-to-glyph-options.html)); scores and estimates unchanged. | | 2026-11-06 | minor | Drop Shadow: a swatch beside Color opens the Color Picker over the dialog, as the other effect colour fields do (#5364); correction: the #5365 entry listed Drop Shadow, whose Color is drawn outside the shared grid and had no swatch; scores unchanged. | From 1e43455d7c11538b7fa0bcd93a66b2bd84c89b94 Mon Sep 17 00:00:00 2001 From: M Balazs PC Date: Sat, 7 Nov 2026 19:44:48 +0100 Subject: [PATCH 32/71] .ai/EPS import: the docs give frame fitting's exact limit and qualify the file format status line (#6137) --- crates/text/src/layout/driver.rs | 4 ++-- crates/text/src/tests.rs | 3 ++- docs/file-format-parity.md | 6 +++--- docs/roadmap.md | 4 ++-- docs/target-app-parity.md | 6 +++--- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/crates/text/src/layout/driver.rs b/crates/text/src/layout/driver.rs index 0c44fee58..9ecb5e9d2 100644 --- a/crates/text/src/layout/driver.rs +++ b/crates/text/src/layout/driver.rs @@ -65,8 +65,8 @@ pub fn fit_first_line(db: &FontDb, t: &mut TextObject) -> bool { /// Area type whose source lays out `lines` lines in its frame, more than fit here: a font that isn't /// available shows in the fallback font, whose lines may reach lower, and the source may set a last /// line whose descent passes the frame's bottom, which isn't drawn here. For importers: the frame is -/// made taller, from its bottom, by at most a line's descent, when that lays out more of the -/// source's lines and no more than it has. Horizontal type only. Whether it was. +/// made taller, from its bottom, by its lines' deepest descent plus 0.05 pt, when that lays out +/// more of the source's lines and no more than it has. Horizontal type only. Whether it was. pub fn fit_source_lines(db: &FontDb, t: &mut TextObject, lines: usize) -> bool { let TextKind::Area { frame } = &t.kind else { return false }; if t.vertical { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index e7526849e..0244f3353 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -305,7 +305,8 @@ fn area_alignment_and_justify() { } /// A source that sets a last line whose descent passes its frame's bottom: the frame grows from its -/// bottom by at most a line's descent, so that line shows, and only as many lines as the source has. +/// bottom by its lines' deepest descent plus 0.05 pt, so that line shows, and only as many lines as +/// the source has. #[test] fn fit_source_lines_keeps_the_last_lines_the_source_sets() { let text = "one\ntwo\nthree"; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index e12eb901d..7fe138e31 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (`.ai` and EPS type from the text document keeps auto leading, hyphenation options and the file's last lines; EPS and `.ai` editing data: a transparency set inside a clipping group stays after its `Q`; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (`.ai` and EPS type from the text document keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; EPS and `.ai` editing data: a transparency set inside a clipping group stays after its `Q`; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -53,7 +53,7 @@ private editing data. VectorCraft: (Auto Leading) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)), and the paragraph's hyphenation options. Area type of one frame whose last lines the file sets but which don't fit here (the fallback font, or a descent - past the frame's bottom) may get a frame taller from its bottom, by at most a line's descent and only when that shows more of those lines, with a warning when it does. + past the frame's bottom) may get a frame taller from its bottom, by its lines' deepest descent plus 0.05 pt and only when that shows more of those lines, with a warning when it does. RGB in the PDF page's `ICCBased` spaces keeps the file's numbers in a `.ai` (the `uncalibrated_rgb` import option, on for editor documents, off for PDFs), so `.ai` files tagged with a non-sRGB RGB profile open with different, unconverted RGB colours than before. @@ -217,7 +217,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| -| 2026-11-07 | minor | EPS and `.ai` type from the text document: auto leading stays auto at the paragraph's share (it was the first two lines' distance for every line, so 10 pt lines under a 15 pt heading came in 18 pt apart), the paragraph's hyphenation options are read, and area type of one frame whose last lines the file sets in too short a frame for them here may get a frame taller by at most a line's descent, when that shows more of them. A user's `.ai` (used locally, not committed) whose type differed from its page by 6.6%, so that it opened from its PDF part, now opens from its layers (4.9%; type with tab stops in right-aligned paragraphs still differs, a separate issue) (#6137). Scores and estimates unchanged. | +| 2026-11-07 | minor | EPS and `.ai` type from the text document: auto leading stays auto at the paragraph's share (it was the first two lines' distance for every line, so 10 pt lines under a 15 pt heading came in 18 pt apart), the paragraph's hyphenation options are read, and area type of one frame whose last lines the file sets in too short a frame for them here may get a frame taller by its lines' deepest descent plus 0.05 pt, when that shows more of them. A user's `.ai` (used locally, not committed) whose type differed from its page by 6.6%, so that it opened from its PDF part, now opens from its layers (4.9%; type with tab stops in right-aligned paragraphs still differs, a separate issue) (#6137). Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: `Q` restores the painting state but not the transparency (`Xy`), which the writer only sets when the next object's differs from the last one written. A Screen glow followed by a clipping group set back to Normal no longer leaves the type after the group in Screen: two users' Illustrator 26.5 and 30 EPS posters (used locally, not committed) now show their gold heading and crescent, and both open from their layers (2.5% and 2.7% from the page before, under the limit after). Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: a blend whose spine was never edited, written with its keys and no spine part, comes in as a live blend along the straight lines between its keys' centres (in its record's order) instead of as its drawn steps. Checked on six users' Illustrator EPS files (used locally, not committed): every blend in four of them now comes in live, their renders unchanged; two still have blends the reader can't match to their drawn steps. Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: the art styles are found before the art is read, so objects in symbols defined ahead of `%AI9_BeginArtStyles` (as Illustrator 24 writes it) come in as themselves with their style instead of their drawn look. Checked on two users' EPS files (used locally, not committed): one's Symbols panel gains live Feather and Round Corners, its drawn-look count drops from 5 to 2, and both render unchanged; own-fixture test in `import::ai::tests`; scores and hours unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index 795c06243..f8fd9ce9c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -36,7 +36,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | Date | Change | Summary | |---|---|---| -| 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps auto leading at the paragraph's share, the paragraph's hyphenation options and may show more of the last lines the file sets in an area frame of its own, when growing the frame by at most a line's descent shows them, and a line a soft return ends is justified as a wrapped line is (#6137); a user's `.ai` that opened from its PDF part now opens from its layers. Limits: frames grow only for area type of one frame, horizontal, by at most a line's descent; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | +| 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps auto leading at the paragraph's share, the paragraph's hyphenation options and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them, and a line a soft return ends is justified as a wrapped line is (#6137); a user's `.ai` that opened from its PDF part now opens from its layers. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-05 | minor | Effect dialogs: a colour swatch beside a hex colour field follows typed values, and a double-click opens the Color Picker over the dialog, whose OK fills the field (#5365); scores and estimates unchanged. | | 2026-11-05 | minor | Selection Tools add-on: Select Linked, Embedded, Missing and Modified select the images and placed documents with that link status (Object › Plug-ins › Selection Tools and the Links panel menu; Shift adds), and Select by Link Status… lists them by name, status and format to pick from, optionally zooming to them; what is hidden, locked, on a template layer or outside the isolated group is left out and the status bar says how many (#5526, a VectorCraft addition); scores and estimates unchanged (estimated). | @@ -124,7 +124,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | | M2 | Drawing tools + smart guides | ✅ mostly done (Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending) | 12–18 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by at most a line's descent shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default | 4–8 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3c78dd43f..db984b699 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -20,7 +20,7 @@ Creating a gradient from solid or empty paint uses the document's color model fo The shared assessment status line and revision history are current upstream; this dated section records the submission's feature evidence and revisions without rescoring upstream or claiming a new combined build or runtime measurement. -- File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading at the paragraph's share of each line's largest type, the paragraph's hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by at most a line's descent shows them; a line a soft return ends is justified as a wrapped line is. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Limits: frames grow only for area type of one frame, horizontal, by at most a line's descent; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently; scores and estimates unchanged +- File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading at the paragraph's share of each line's largest type, the paragraph's hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them; a line a soft return ends is justified as a wrapped line is. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently; scores and estimates unchanged - Current-main conflict reconciliation retains upstream and feature behavior; scores and estimates unchanged - Superseded 2026-10-20 behavior, retained as history: Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting uses the original target artboard and revision-scoped bounds caches (scores/hours unchanged) @@ -32,7 +32,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| -| 2026-11-07 | minor | File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, may show more of the last lines the file sets in an area frame of its own, when growing the frame by at most a line's descent shows them, and a line a soft return ends is justified; evidence added. Limits: frames grow only for area type of one frame, horizontal, by at most a line's descent; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | +| 2026-11-07 | minor | File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them, and a line a soft return ends is justified; evidence added. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | | 2026-11-07 | minor | The canvas keeps selection outlines, anchors and handles between frames, so an idle frame with a large selection redraws them as one mesh; scores and estimates unchanged | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-06 | minor | Stroke gradients: an arrowhead whose tip sits on the end of a long straight leg is painted by the gradient to the corners of its base. The slices that reach across a head now run to the end of its reach instead of narrowing towards the next corner (#5964). Test: `stroke::tests_gradient::slices_cover_an_arrowhead_on_a_long_straight_leg`. Scores and estimates unchanged. | @@ -534,7 +534,7 @@ since 2026-10-01. - **Canvas review follow-up:** Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Controlled-worker regression checks oversized width and height, high DPI and the render transform without allocating oversized images. Scores and estimates are unchanged. - **Cached canvas frames during viewport changes:** Cached screen textures use their own raster dimensions and pixels-per-point when reprojected. `canvas::tests::cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes` holds the worker and checks every UV corner against the cached render transform, including isolated viewport-only resize at zero/30-degree rotation, fractional raster dimensions, zoom/pan, changed rotation, monitor-DPI changes in both directions, Pixel Preview and replacement-frame delivery. The contributor rebuilt and confirmed the original resize issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. - **PDF editable import:** Place from bytes or a path with `link: false`, and `clipboard.importPdf` followed by Paste, preserve live type on the selected page. Place still honors `textAs: "outlines"`. Synthetic two-page regressions: `crates/engine/src/cmd/fileio/tests_pdffidelity.rs`. No change to the PDF reader's existing fallback rules. -- **`.ai`/EPS type laid out closer to the file's page (#6137):** type from the text document keeps auto leading as auto, each line taking the paragraph's Auto Leading share (key 7) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)); the paragraph's hyphenation options (keys 10 to 15) are read; area type of one frame whose last lines the file sets, but which don't fit here, may get a frame taller from its bottom, by at most a line's descent and only when that shows more of those lines and no more than the file has (`vectorcraft_text::fit_source_lines`), with a warning when it does: the frame can stay as it is, or show only some of them; and a line a soft return ends is justified as a wrapped line is, a paragraph's last line keeping its alignment. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_auto_leading`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Limits: frames grow only for area type of one frame, horizontal, by at most a line's descent; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores unchanged. +- **`.ai`/EPS type laid out closer to the file's page (#6137):** type from the text document keeps auto leading as auto, each line taking the paragraph's Auto Leading share (key 7) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)); the paragraph's hyphenation options (keys 10 to 15) are read; area type of one frame whose last lines the file sets, but which don't fit here, may get a frame taller from its bottom, by its lines' deepest descent plus 0.05 pt and only when that shows more of those lines and no more than the file has (`vectorcraft_text::fit_source_lines`), with a warning when it does: the frame can stay as it is, or show only some of them; and a line a soft return ends is justified as a wrapped line is, a paragraph's last line keeping its alignment. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_auto_leading`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores unchanged. - **Selected Grid Repeat handles (#2691):** `tools/src/repeat_grid.rs` tests place the handles outside the grid at zoom 0.5, 1 and 4, preview rows, columns and spacing, leave no undo step for a drag that ends where it began, and give bounding-box handles priority; a headless canvas regression drags the row handle two tiles down in a zoomed view (3 → 5 rows, the source unchanged, one Undo back) and the column-spacing slider. Scores and hours unchanged. - **Selected Mirror Repeat handles:** a headless canvas regression creates the default mirror repeat and drags its Top bounding-box handle where the axis crosses it; the repeat grows vertically while its width stays fixed. Existing mirror-axis checks cover center movement and end-handle hit priority. The focused UI mirror tests pass. Scores and hours unchanged. - **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. From 68609fb43b66f401e9064c617aba5e2c0dcbb7b2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 22:07:20 -0800 Subject: [PATCH 33/71] Plug-ins: filters at API level 2 read type, images and symbol instances A filter manifest at `api` 2 may list `inputs` (`type`, `image`, `symbolInstance`). Such a filter is also given the selected objects of those kinds, read only: type with its runs and paragraph attributes, images with their pixel size, color mode and, when embedded and small enough, their RGBA pixels in base64 (never a linked file's pixels or path), and symbol instances with their symbol and transform. The output must return each read-only object as {"id": ...} alone or exactly as given. A change, an omission or a duplicate is refused before the edit, so the document and undo history stay as they were, including for output handed in from a heavy run. A filter without `inputs` gets the same input bytes as before. A manifest that lists `inputs` below `api` 2, outside a filter, with an unknown or repeated kind, or with a permission list that lacks `doc.read` does not install. Part of #4333 --- Cargo.lock | 2 + crates/engine/src/cmd/plugin.rs | 32 ++- crates/engine/src/tests/plugins.rs | 50 +++++ crates/engine/tests/plugin_fuzz.rs | 55 ++++++ crates/plugins/Cargo.toml | 3 + crates/plugins/src/lib.rs | 5 +- crates/plugins/src/manifest.rs | 127 +++++++++++- crates/plugins/src/objects.rs | 297 ++++++++++++++++++++++++++-- crates/plugins/src/objects/tests.rs | 238 ++++++++++++++++++++++ crates/plugins/src/permissions.rs | 3 + crates/ui-egui/src/dialogs/tests.rs | 1 + crates/ui-egui/src/i18n/ca.tsv | 5 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fi.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/hi.tsv | 5 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ka.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/sv.tsv | 5 + crates/ui-egui/src/i18n/tr.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/vi.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + docs/mcp.md | 5 +- docs/plugins.md | 62 +++++- docs/target-app-parity.md | 1 + 36 files changed, 956 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 72d591b91..025810f08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6422,6 +6422,8 @@ dependencies = [ name = "vectorcraft-plugins" version = "0.21.0" dependencies = [ + "craft-vectorcraft-format", + "image", "log", "serde", "serde_json", diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 6593f7a79..316221488 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -25,7 +25,12 @@ //! [`vectorcraft_doc::cancel::scope`] (the app's heavy filters and background exports), its //! progress goes to the scope's sink and it sees the scope's Cancel; its log lines go to the log //! under its id. +//! +//! A filter whose manifest lists `inputs` (plug-in API level 2) is also given the selected type, +//! images or symbol instances of those kinds, to read only: its output must return each of them +//! unchanged ([`objects::decode_output_with`]). +use std::collections::HashMap; use std::sync::{Arc, Mutex}; use serde_json::{Map, Value, json}; @@ -64,6 +69,9 @@ pub fn describe(p: &Plugin) -> Value { if m.budget == Budget::Heavy { v["budget"] = json!("heavy"); } + if !m.inputs.is_empty() { + v["inputs"] = json!(m.inputs); + } v } @@ -98,6 +106,9 @@ pub struct FilterRun { params: Vec, /// The objects the plug-in is given, and the container new objects go in. inputs: Vec, + /// The type, images and symbol instances among them (a filter that asks for them), as given: + /// its output must return each one unchanged. + read_only: HashMap, parent: Option, /// The document it was made from, and that document's revision then. document: (u64, u64), @@ -140,8 +151,10 @@ impl FilterRun { let may = m.permissions(); let st = s.doc()?; let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone()); - // Without `doc.read` a filter is given no objects: it can only add new ones. - let inputs = if may.contains(&Permission::DocRead) { objects::filter_targets(&st.doc, &selected) } else { Vec::new() }; + // Without `doc.read` a filter is given no objects: it can only add new ones. Type, images + // and symbol instances only when its manifest asks for them (`inputs`). + let kinds = m.input_kinds(); + let inputs = if may.contains(&Permission::DocRead) { objects::filter_targets_with(&st.doc, &selected, &kinds) } else { Vec::new() }; let parent = st.insertion_parent(); let bounds = st.doc.bounds_of(&inputs, false); let board = bounds.and_then(|b| st.doc.artboard_at(b.center())).and_then(|i| st.doc.artboards.get(i)).or(st.doc.artboards.first()); @@ -149,10 +162,10 @@ impl FilterRun { // What the plug-in keeps with the document, as it last wrote it. let data = st.doc.plugin_data(&m.id).filter(|_| may.contains(&Permission::State)).cloned().unwrap_or(Value::Null); params["_context"] = json!({"mode": "filter", "bounds": bounds.map(rect), "artboard": board.map(|a| rect(a.rect)), "documentData": data}); - let input = objects::encode_input(&st.doc, &inputs); + let (input, read_only) = objects::encode_input_with(&st.doc, &inputs, &kinds); let params = serde_json::to_vec(¶ms).map_err(|e| bad(C, e.to_string()))?; let document = (st.uid, st.revision); - Ok(FilterRun { plugin, input, params, inputs, parent, document }) + Ok(FilterRun { plugin, input, params, inputs, read_only, parent, document }) } /// The plug-in, for its name and budget. @@ -192,12 +205,13 @@ impl FilterRun { } /// Applies the plug-in's `output` to the active document as one undo step: its objects, and what - /// it keeps with the document from now on. An output its permissions don't allow is refused, - /// and the document stays as it was. + /// it keeps with the document from now on. An output its permissions don't allow, and one that + /// changes or leaves out a read-only object it was given, is refused, and the document stays as + /// it was. fn finish(self, s: &mut Session, output: &[u8]) -> Result { let m = self.plugin.manifest(); let may = m.permissions(); - let out = objects::decode_output(output).map_err(err)?; + let out = objects::decode_output_with(output, &self.read_only).map_err(err)?; if out.document_data.is_some() && !may.contains(&Permission::State) { return Err(not_granted(&m.name, Permission::State)); } @@ -499,7 +513,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; progress: it may report its progress, see Cancel and write log lines), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", + "{} → {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), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", always, list ), @@ -517,7 +531,7 @@ pub fn specs() -> Vec { "Run Plug-in", [], None, - "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}", + "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. A filter whose manifest lists inputs (type, image, symbolInstance) is also given the selected objects of those kinds to read: its output returns each as {id} or exactly as given, or the run is refused and nothing changes. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}", has_doc, run ), diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index fdfe60067..ee67fe603 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -660,6 +660,56 @@ fn a_filter_does_only_what_its_permissions_allow() { assert_eq!(s.doc().unwrap().doc.plugin_data(stateless), Some(&json!({"pen": 1}))); } +/// A filter whose manifest lists `inputs` is also given the selected type, read-only: an echo +/// changes nothing, and an output that leaves the type out or changes it is refused with the +/// document and undo history as they were, handed in from a run made elsewhere too. A filter +/// without `inputs` is not given the type and so never deletes it. +#[test] +fn read_only_kind_field_and_omission_refuse_without_undo() { + use cmd::plugin::FilterRun; + let mut s = session(); + let r = rect(&mut s, 10.0, "#ff0000"); + let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 80, "text": "Hi"}))["id"].as_u64().unwrap()); + let reads = |id: &str| format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"inputs":["type"]}}"#); + let echo = "test.engine.inputs.echo"; + let listed = install_wat(&mut s, &tpl::module(&reads(echo), tpl::ECHO, "", 1)); + assert_eq!(listed["inputs"], json!(["type"])); + let p = |id: &str| json!({"id": id, "ids": [r.0, t.0]}); + let before = s.doc().unwrap().doc.clone(); + assert_eq!(run(&mut s, "plugin.run", p(echo))["ids"], json!([r.0, t.0])); + assert_eq!(s.doc().unwrap().doc.layers, before.layers, "an echo changes nothing"); + for (output, says) in [ + (json!({"objects": [{"id": r.0}]}), format!("objects (id {}): a read-only object was left out", t.0)), + (json!({"objects": [{"id": r.0}, {"id": t.0, "name": "Renamed"}]}), format!("objects[1] (id {}): read-only field 'name' changed", t.0)), + (json!({"objects": [{"id": r.0}, {"id": t.0}, {"type": "type", "path": triangle()}]}), "`type` must be \"path\" or \"compound\"".into()), + ] { + let id = "test.engine.inputs.bad"; + install_wat(&mut s, &tpl::returning(&reads(id), &output.to_string())); + let (doc, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let e = s.execute("plugin.run", &p(id)).unwrap_err().to_string(); + assert!(e.contains(&says), "{says}: {e}"); + assert_eq!((&s.doc().unwrap().doc, s.doc().unwrap().history.undo.len()), (&doc, undo), "{output}"); + // Handed in from a run made away from plugin.run, it is refused the same way. + let made = FilterRun::prepare(&s, &p(id)).unwrap(); + let out = made.run().unwrap(); + made.hand_in(&mut s, out); + let e = s.execute("plugin.run", &p(id)).unwrap_err().to_string(); + assert!(e.contains(&says), "handed in: {e}"); + assert_eq!((&s.doc().unwrap().doc, s.doc().unwrap().history.undo.len()), (&doc, undo), "handed in: {output}"); + } + // Without `inputs` the type isn't given, so leaving it out keeps it. + let legacy = "test.engine.inputs.legacy"; + install_wat(&mut s, &tpl::returning(&tpl::manifest(legacy, "filter", "{}"), &json!({"objects": [{"id": r.0, "name": "Kept"}]}).to_string())); + assert_eq!(run(&mut s, "plugin.run", p(legacy))["ids"], json!([r.0])); + assert_eq!(node(&s, r).name.as_deref(), Some("Kept")); + assert!(matches!(node(&s, t).kind, vectorcraft_doc::NodeKind::Text(_)), "the type stays"); + // A manifest that lists `inputs` below `api` 2 doesn't install. + let old = format!(r#"{{"id":"{echo}","name":"Old","kind":"filter","inputs":["type"]}}"#); + let data = vectorcraft_format::base64_encode(&wat::parse_str(tpl::module(&old, tpl::ECHO, "", 1)).unwrap()); + let e = s.execute("plugin.install", &json!({"dataBase64": data})).unwrap_err().to_string(); + assert!(e.contains("`inputs` needs `api` 2"), "{e}"); +} + /// What the user granted is kept in the plug-in store with the module: the next session installs /// the plug-in again without asking. A stored plug-in that lists permissions nobody granted it is /// skipped, even when another store granted it, and so is one in the Additional Plug-ins Folder diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index 1791555f7..5dc2e2a5e 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -362,4 +362,59 @@ proptest! { prop_assert!(total == 0 || done <= total); } } + + /// Manifests with hostile `inputs` (unknown, repeated and oddly cased kinds, long lists, junk of + /// every type) with any `api`, kind and permissions parse to an error or to a filter at `api` 2 + /// or later asking for each kind once. One that installs and echoes its input on a document + /// with type, images and symbols leaves the document as it was, and it still renders. + #[test] + fn hostile_manifest_inputs_never_crash( + kind in prop::sample::select(vec!["filter", "effect", "exporter"]), + names in prop::collection::vec(prop::sample::select(vec!["type", "image", "symbolInstance", "Type", "text", "path", ""]), 0..6), + long in prop::option::weighted(0.1, 4usize..300), + inputs in prop::option::weighted(0.8, prop_oneof![Just(Value::Null), arb_junk()]), + api in prop_oneof![2 => Just(json!(2)), 1 => Just(json!(1)), 1 => Just(json!(3)), 1 => arb_junk()], + permissions in prop_oneof![Just(Value::Null), Just(json!(["doc.read", "doc.write"])), Just(json!(["doc.write"]))], + ) { + let mut m = json!({"id": "fuzz.inputs", "name": "Inputs", "kind": kind, "api": api}); + if kind == "exporter" { + m["extensions"] = json!(["fz"]); + } + if !permissions.is_null() { + m["permissions"] = permissions; + } + m["inputs"] = match (inputs, long) { + (_, Some(n)) => json!(vec!["image"; n]), + (Some(Value::Null) | None, None) => json!(names), + (Some(junk), None) => 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 { + let kinds = man.input_kinds(); + prop_assert_eq!(kinds.len(), man.inputs.len(), "{}", text); + prop_assert!(kinds.iter().enumerate().all(|(i, k)| !kinds[..i].contains(k)), "{}", text); + prop_assert!(kinds.is_empty() || (man.kind == vectorcraft_plugins::Kind::Filter && man.api >= 2), "{}", text); + } + let mut s = rich_session(); + let module = wat::parse_str(tpl::module(&text, tpl::ECHO, "", 1)).unwrap(); + let data = vectorcraft_format::base64_encode(&module); + let grant = json!(["doc.read", "doc.write", "state", "effect.render", "export.write"]); + let before = s.doc().unwrap().doc.clone(); + let r = catch_quiet(|| { + let installed = s.execute("plugin.install", &json!({"dataBase64": data, "grant": grant})).is_ok(); + let _ = s.execute("select.all", &json!({})); + let ran = installed && s.execute("plugin.run", &json!({"id": "fuzz.inputs"})).is_ok(); + (installed, ran) + }); + let (installed, ran) = r.map_err(|msg| TestCaseError::fail(format!("panicked running {text}: {msg}")))?; + prop_assert!(!installed || parsed.is_ok(), "a manifest that doesn't parse installed: {}", text); + if ran { + prop_assert!(s.doc().unwrap().doc.layers == before.layers, "an echo changed the document: {}", text); + } + renders(&s, &text)?; + vectorcraft_plugins::registry::remove("fuzz.inputs"); + vectorcraft_plugins::registry::revoke("fuzz.inputs"); + } } diff --git a/crates/plugins/Cargo.toml b/crates/plugins/Cargo.toml index 5e4117a18..7c18f2c06 100644 --- a/crates/plugins/Cargo.toml +++ b/crates/plugins/Cargo.toml @@ -15,6 +15,9 @@ vectorcraft-doc = { workspace = true } log = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +# The pixels of a small image a filter asks to read, as RGBA in base64. +image = { workspace = true } +craft-vectorcraft-format = { workspace = true } # The SHA-256 of a module, which the install prompt shows and a grant records. sha2 = { version = "0.10", default-features = false } thiserror = { workspace = true } diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index 5e5da17b9..8bbc2906b 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -16,7 +16,8 @@ //! a module does can panic or hang the host. //! //! Plug-ins see the document as JSON ([`objects`]): the selected paths and compound paths with -//! their geometry, fills, strokes (gradients included) and opacity. An **object filter** returns +//! their geometry, fills, strokes (gradients included) and opacity, and, for a filter that asks +//! for them ([`InputKind`]), type, images and symbol instances to read. An **object filter** returns //! 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 @@ -39,7 +40,7 @@ pub mod wat; pub use dialog::ParamUi; pub use host::{LogLevel, LogLine, Report}; -pub use manifest::{Budget, Kind, Manifest, Param, ParamSpec}; +pub use manifest::{Budget, InputKind, 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 diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 36b4b78c4..0016c533c 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use crate::dialog::ParamUi; -use crate::permissions::{self, API_LEVEL, PROGRESS_API, Permission}; +use crate::permissions::{self, API_LEVEL, INPUTS_API, PROGRESS_API, Permission}; use crate::{Error, Result}; /// Longest manifest the host reads. @@ -46,6 +46,39 @@ impl Kind { } } +/// A kind of object a filter may ask to be given besides paths and compound paths (its manifest's +/// `inputs`, plug-in API level 2). The filter reads these objects and returns each one as it was +/// given: it can't change or delete them ([`crate::objects::decode_output_with`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum InputKind { + /// Point, area and path type: its text runs with their character attributes, and its + /// paragraph attributes. + Type, + /// An embedded or linked image: its size, color mode and, when small, its pixels. + Image, + /// A symbol instance: the symbol it shows and its transform. + SymbolInstance, +} + +impl InputKind { + /// Every kind, in the order the docs list them. + pub const ALL: [InputKind; 3] = [InputKind::Type, InputKind::Image, InputKind::SymbolInstance]; + + /// Its name in `inputs`, which is also the `type` of such an object in a filter's input. + pub fn name(self) -> &'static str { + match self { + InputKind::Type => "type", + InputKind::Image => "image", + InputKind::SymbolInstance => "symbolInstance", + } + } + + /// The kind named `name`. + pub fn parse(name: &str) -> Option { + InputKind::ALL.into_iter().find(|k| k.name() == name) + } +} + /// How much one run of a plug-in may compute. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -250,6 +283,11 @@ pub struct Manifest { /// installed. Left out, it gets the ones its kind implies ([`Permission::implied`]). #[serde(default, skip_serializing_if = "Option::is_none")] pub permissions: Option>, + /// The kinds of object a filter asks to be given besides paths and compound paths, by + /// [`InputKind::name`]: only a filter at `api` 2 or later lists them. Left out or empty, it is + /// given paths and compound paths alone. + #[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "input_names")] + pub inputs: Vec, /// 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)>, @@ -285,6 +323,7 @@ impl Manifest { author: String::new(), api: 1, permissions: None, + inputs: Vec::new(), params: Vec::new(), extensions: Vec::new(), budget: Budget::Light, @@ -313,6 +352,7 @@ impl Manifest { { return Err(Error::Manifest(format!("permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API}"))); } + self.validate_inputs()?; self.validate_extensions()?; match (self.budget, self.kind) { (Budget::Light, _) | (Budget::Heavy, Kind::Filter) => {} @@ -336,6 +376,38 @@ impl Manifest { Ok(()) } + /// `inputs` names each [`InputKind`] at most once, and only a filter at `api` 2 or later lists + /// any, with `doc.read` among its permissions when it lists them. + fn validate_inputs(&self) -> Result<()> { + if self.inputs.is_empty() { + return Ok(()); + } + if self.kind != Kind::Filter { + return Err(Error::Manifest("only a filter declares `inputs`".into())); + } + for (i, name) in self.inputs.iter().enumerate() { + let shown = permissions::display_text(name, permissions::MAX_SHOWN_FIELD); + if InputKind::parse(name).is_none() { + return Err(Error::Manifest(format!("`inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance"))); + } + if self.inputs.get(..i).is_some_and(|before| before.contains(name)) { + return Err(Error::Manifest(format!("`inputs` lists `{shown}` twice"))); + } + } + if self.api < INPUTS_API { + return Err(Error::Manifest("`inputs` needs `api` 2 or later".into())); + } + if self.permissions.as_ref().is_some_and(|p| !p.iter().any(|n| n == Permission::DocRead.name())) { + return Err(Error::Manifest("`inputs` needs the permission `doc.read`".into())); + } + Ok(()) + } + + /// The kinds of object besides paths and compound paths it asks to be given (`inputs`). + pub fn input_kinds(&self) -> Vec { + self.inputs.iter().filter_map(|n| InputKind::parse(n)).collect() + } + /// An exporter 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<()> { @@ -491,6 +563,17 @@ fn ordered_params<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result d.deserialize_any(V) } +/// Reads `inputs`: a list of names (`null`: none). Anything else is an error that names the key, as +/// [`Manifest::validate_inputs`] names a wrong entry. +fn input_names<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result, D::Error> { + let wrong = || serde::de::Error::custom("`inputs` must be a list of object kinds: type, image or symbolInstance"); + match Value::deserialize(d)? { + Value::Null => Ok(Vec::new()), + Value::Array(names) => names.into_iter().map(|n| if let Value::String(s) = n { Ok(s) } else { Err(wrong()) }).collect(), + _ => Err(wrong()), + } +} + fn params_as_object(p: &[(String, Param)], s: S) -> std::result::Result { use serde::ser::SerializeMap; let mut m = s.serialize_map(Some(p.len()))?; @@ -674,6 +757,48 @@ mod tests { } } + /// A filter at `api` 2 may ask for type, images and symbol instances (`inputs`), each kind once, + /// with `doc.read` when it lists permissions. Left out or empty, it asks for none and isn't + /// written back. Anything else is refused naming `inputs`. + #[test] + fn a_filter_at_api_2_lists_the_inputs_it_reads() { + let m = + Manifest::parse(br#"{"id":"org.example.inspect","name":"Inspect","kind":"filter","api":2,"inputs":["type","image","symbolInstance"]}"#) + .unwrap(); + assert_eq!(m.input_kinds(), InputKind::ALL); + assert_eq!(serde_json::to_value(&m).unwrap()["inputs"], json!(["type", "image", "symbolInstance"])); + let some = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["doc.read"],"inputs":["image"]}"#).unwrap(); + assert_eq!(some.input_kinds(), [InputKind::Image]); + for none in [ + r#"{"id":"a","name":"x","kind":"filter"}"#, + r#"{"id":"a","name":"x","kind":"filter","inputs":[]}"#, + r#"{"id":"a","name":"x","kind":"filter","inputs":null}"#, + r#"{"id":"a","name":"x","kind":"effect","inputs":[]}"#, + ] { + let m = Manifest::parse(none.as_bytes()).unwrap(); + assert!(m.input_kinds().is_empty(), "{none}"); + assert!(serde_json::to_value(&m).unwrap().get("inputs").is_none(), "{none}"); + } + for (bad, says) in [ + (r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":["text"]}"#, "`inputs` names an unknown object kind `text`"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":["Type"]}"#, "unknown object kind `Type`"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":["image","image"]}"#, "`inputs` lists `image` twice"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":"type"}"#, "`inputs` must be a list"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":[1]}"#, "`inputs` must be a list"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":{"type":true}}"#, "`inputs` must be a list"), + (r#"{"id":"a","name":"x","kind":"filter","inputs":["type"]}"#, "`inputs` needs `api` 2"), + (r#"{"id":"a","name":"x","kind":"filter","api":1,"inputs":["type"]}"#, "`inputs` needs `api` 2"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["doc.write"],"inputs":["type"]}"#, "needs the permission `doc.read`"), + (r#"{"id":"a","name":"x","kind":"effect","api":2,"inputs":["type"]}"#, "only a filter declares `inputs`"), + (r#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"],"api":2,"inputs":["image"]}"#, "only a filter declares `inputs`"), + ] { + let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string(); + assert!(e.contains(says), "{bad}: {e}"); + } + let long = format!(r#"{{"id":"a","name":"x","kind":"filter","api":2,"inputs":[{}]}}"#, vec![r#""type""#; 1000].join(",")); + assert!(Manifest::parse(long.as_bytes()).unwrap_err().to_string().contains("twice")); + } + #[test] fn strings_and_number_lists_resolve_like_the_other_types() { let m = Manifest::parse( diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index 037591f8b..9fa37ce54 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -10,10 +10,12 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer}; -use vectorcraft_geom::{FillRule, PathData, Point, Rect}; +use vectorcraft_doc::{ + Appearance, AppearanceItem, Document, Effect, FillLayer, ImageBlob, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer, TextKind, TextObject, +}; +use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect}; -use crate::{Error, Result}; +use crate::{Error, InputKind, Result}; /// Coordinates a plug-in may return, in points (the document's own canvas limit). pub const MAX_COORD: f64 = 4.0e6; @@ -32,6 +34,14 @@ pub const MITER_LIMIT: std::ops::RangeInclusive = 1.0..=500.0; pub const MAX_EFFECT_ID: usize = 128; /// Largest data a plug-in may keep with a document (`documentData`), in bytes of JSON. pub const MAX_DOCUMENT_DATA: usize = 1 << 20; +/// Most text a filter is given of one type object, in bytes of UTF-8 over all its runs: longer +/// type comes with `truncated` and no runs or paragraphs. +pub const MAX_INPUT_TEXT: usize = 256 * 1024; +/// Most runs, and most paragraphs, a filter is given of one type object (as [`MAX_INPUT_TEXT`]). +pub const MAX_INPUT_RUNS: usize = 4_096; +/// Largest pixels a filter is given of one embedded image, in characters of base64: a larger +/// image comes with `pixelsOmitted`. +pub const MAX_INPUT_PIXELS: usize = 1 << 20; /// One object returned by a plug-in. `None` fields keep the object's value (new objects take the /// defaults: a path, non-zero rule, no fills or strokes, full opacity). @@ -56,6 +66,9 @@ pub struct OutObject { pub guide: Option, /// Locks or unlocks the object (as Object › Lock and Unlock do). pub locked: Option, + /// A read-only input object (type, an image or a symbol instance) returned as it was given: + /// it stays as it is, and new objects listed after it go above it. + pub kept: bool, } /// One stroke of an [`OutObject`]. `None` fields keep the stroke's value (a new stroke's @@ -158,8 +171,153 @@ pub fn encode(n: &Node) -> Option { /// The input document of a filter run: `{"objects": [...]}` for `ids` (paths and compound paths; /// others are left out). pub fn encode_input(doc: &Document, ids: &[NodeId]) -> Vec { - let objects: Vec = ids.iter().filter_map(|id| doc.node(*id)).filter_map(encode).collect(); - serde_json::to_vec(&json!({ "objects": objects })).unwrap_or_default() + encode_input_with(doc, ids, &[]).0 +} + +/// [`encode_input`] for a filter that asks for `kinds` too: objects of those kinds among `ids` are +/// in it as [`encode_read_only`] gives them. Also returns those objects by id, which the filter must +/// return as they were ([`decode_output_with`]). +pub fn encode_input_with(doc: &Document, ids: &[NodeId], kinds: &[InputKind]) -> (Vec, HashMap) { + let mut read_only = HashMap::new(); + let objects: Vec = ids + .iter() + .filter_map(|id| doc.node(*id)) + .filter_map(|n| match input_kind(n).filter(|k| kinds.contains(k)) { + Some(_) => { + let o = encode_read_only(doc, n)?; + read_only.insert(n.id, o.clone()); + Some(o) + } + None => encode(n), + }) + .collect(); + (serde_json::to_vec(&json!({ "objects": objects })).unwrap_or_default(), read_only) +} + +/// The kind of read-only object `n` is: type, an image or a symbol instance (`None` for others). +pub fn input_kind(n: &Node) -> Option { + match &n.kind { + NodeKind::Text(_) => Some(InputKind::Type), + NodeKind::Image(_) => Some(InputKind::Image), + NodeKind::SymbolInstance { .. } => Some(InputKind::SymbolInstance), + _ => None, + } +} + +fn affine_json(xf: Affine) -> Value { + json!(xf.as_coeffs()) +} + +/// Type, an image or a symbol instance of `doc` as a filter that asks for its kind reads it (`None` +/// for other objects). Each has `{id, type, name?, bounds, transform}`, `transform` mapping its own +/// space (text space, pixel space, the symbol's art) into the document, and: +/// - type (`"type"`): `text: {kind: "point"|"area"|"path", vertical, runs: [{text, style, inline?}], +/// paragraphs: [style]}` with the native character and paragraph attributes, one paragraph style +/// for each paragraph, and `truncated`: true, with no runs or paragraphs, past [`MAX_INPUT_TEXT`] +/// or [`MAX_INPUT_RUNS`]; +/// - an image (`"image"`): `pixelSize: [w, h]`, `colorMode: "rgb"|"cmyk"|"gray"|"unknown"`, +/// `linked`, `pixelsOmitted`, and for an embedded image of at most [`MAX_INPUT_PIXELS`] +/// `pixelsBase64`: straight RGBA, 8 bits a channel, row by row from the top. A linked image's file +/// and path are never read or given; +/// - a symbol instance (`"symbolInstance"`): `symbolId` (the symbol's name), `missing: true` when +/// the document has no such symbol, and `overrides: {}`. +pub fn encode_read_only(doc: &Document, n: &Node) -> Option { + let mut o = json!({"id": n.id.0, "type": input_kind(n)?.name(), "bounds": rect_json(doc.bounds_of(&[n.id], false))}); + match &n.kind { + NodeKind::Text(t) => { + o["transform"] = affine_json(t.xf); + let (text, truncated) = text_json(t); + o["text"] = text; + o["truncated"] = json!(truncated); + } + NodeKind::Image(im) => { + o["transform"] = affine_json(im.xf); + let linked = im.link.is_some(); + let blob = doc.image_blob(im); + // A linked image's pixels are its file's: never given (nor its path). + let pixels = blob.filter(|_| !linked).and_then(|b| rgba_pixels(b, im.width, im.height)); + o["pixelSize"] = json!([im.width, im.height]); + o["colorMode"] = json!(blob.map_or("unknown", color_mode)); + o["linked"] = json!(linked); + o["pixelsOmitted"] = json!(pixels.is_none()); + if let Some(p) = pixels { + o["pixelsBase64"] = json!(craft_vectorcraft_format::base64::encode(&p)); + } + } + NodeKind::SymbolInstance { symbol, xf } => { + o["transform"] = affine_json(*xf); + o["symbolId"] = json!(symbol); + if !doc.symbols.iter().any(|s| s.name == *symbol) { + o["missing"] = json!(true); + } + o["overrides"] = json!({}); + } + _ => return None, + } + if let Some(name) = &n.name { + o["name"] = json!(name); + } + Some(o) +} + +/// The text of type `t` as a filter reads it, and whether it was too long to give +/// ([`MAX_INPUT_TEXT`], [`MAX_INPUT_RUNS`]). +fn text_json(t: &TextObject) -> (Value, bool) { + let kind = match t.kind { + TextKind::Point => "point", + TextKind::Area { .. } => "area", + TextKind::OnPath { .. } => "path", + }; + let bytes = t.runs.iter().fold(0usize, |n, r| n.saturating_add(r.text.len())); + let paragraphs = t.paragraph_count(); + let truncated = bytes > MAX_INPUT_TEXT || t.runs.len() > MAX_INPUT_RUNS || paragraphs > MAX_INPUT_RUNS; + let (runs, paras): (Vec, Vec) = if truncated { + (Vec::new(), Vec::new()) + } else { + let value = |v: std::result::Result| v.unwrap_or(Value::Null); + ( + t.runs.iter().map(|r| value(serde_json::to_value(r))).collect(), + (0..paragraphs).map(|i| value(serde_json::to_value(t.para_at(i)))).collect(), + ) + }; + (json!({"kind": kind, "vertical": t.vertical, "runs": runs, "paragraphs": paras}), truncated) +} + +/// How image `b` stores its colors, read from its header: `"cmyk"`, `"gray"`, `"rgb"`, or +/// `"unknown"` when it can't be read or only a linked image's preview is there. +fn color_mode(b: &ImageBlob) -> &'static str { + if b.proxy.as_ref().is_some_and(|p| Arc::ptr_eq(p, &b.bytes) || **p == *b.bytes) { + return "unknown"; + } + if b.is_cmyk() { + return "cmyk"; + } + if b.gray_source().is_some() { + return "gray"; + } + let readable = + image::ImageReader::new(std::io::Cursor::new(b.bytes.as_slice())).with_guessed_format().ok().and_then(|r| r.into_dimensions().ok()); + if readable.is_some() { "rgb" } else { "unknown" } +} + +/// The pixels of image `b` as straight RGBA, when it is `w` × `h` pixels and they fit +/// [`MAX_INPUT_PIXELS`] in base64; the size is checked from the header before anything is decoded. +fn rgba_pixels(b: &ImageBlob, w: u32, h: u32) -> Option> { + let bytes = u64::from(w) * u64::from(h) * 4; + if bytes == 0 || bytes.div_ceil(3) * 4 > MAX_INPUT_PIXELS as u64 { + return None; + } + let reader = || image::ImageReader::new(std::io::Cursor::new(b.bytes.as_slice())).with_guessed_format().ok(); + if reader()?.into_dimensions().ok()? != (w, h) { + return None; + } + let mut r = reader()?; + let mut limits = image::Limits::default(); + // The decoder's own buffers besides the RGBA it gives. + limits.max_alloc = Some(64 << 20); + r.limits(limits); + let img = r.decode().ok()?.into_rgba8(); + (img.dimensions() == (w, h)).then(|| img.into_raw()) } fn out_err(at: &str, msg: impl std::fmt::Display) -> Error { @@ -397,8 +555,16 @@ pub struct Output { /// Parses a filter's output: `{"objects": [...], "documentData"?: …}`, or `{"error": "message"}` /// when the plug-in declined (the message reaches the user). pub fn decode_output(bytes: &[u8]) -> Result { + decode_output_with(bytes, &HashMap::new()) +} + +/// [`decode_output`] for a filter given `read_only` objects ([`encode_input_with`]): it must list +/// each of them exactly once, as `{"id": …}` alone or exactly as it was given (numbers compared by +/// value), and such an entry is [`OutObject::kept`]. Any other entry for one of them, and one left +/// out, is refused naming the object and the first field that differs, before anything changes. +pub fn decode_output_with(bytes: &[u8], read_only: &HashMap) -> Result { let mut v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?; - let objects = decode_objects(&v)?; + let objects = decode_objects(&v, read_only)?; let document_data = match v.get_mut("documentData").map(Value::take) { None => None, Some(Value::Null) => Some(None), @@ -417,11 +583,12 @@ pub fn decode_output(bytes: &[u8]) -> Result { /// declined (the message reaches the user). A live effect's other keys don't matter. pub fn decode(bytes: &[u8]) -> Result> { let v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?; - decode_objects(&v) + decode_objects(&v, &HashMap::new()) } -/// The objects of output `v`, or its decline. -fn decode_objects(v: &Value) -> Result> { +/// The objects of output `v`, or its decline; entries for `read_only` objects as +/// [`decode_output_with`] reads them. +fn decode_objects(v: &Value, read_only: &HashMap) -> Result> { if let Some(e) = declined(v) { return Err(e); } @@ -430,7 +597,83 @@ fn decode_objects(v: &Value) -> Result> { return Err(Error::Output(format!("{} objects (limit {MAX_OBJECTS})", objects.len()))); } let mut anchors = 0; - objects.iter().enumerate().map(|(i, o)| decode_object(o, &format!("objects[{i}]"), &mut anchors)).collect() + let mut returned: HashSet = HashSet::new(); + let out = objects + .iter() + .enumerate() + .map(|(i, o)| { + let id = o.get("id").and_then(Value::as_u64).map(NodeId); + match id.and_then(|id| Some((id, read_only.get(&id)?))) { + Some((id, given)) => { + let at = format!("objects[{i}] (id {})", id.0); + if !returned.insert(id) { + return Err(out_err(&at, "is listed twice")); + } + check_unchanged(o, given, &at)?; + Ok(OutObject { id: Some(id), kept: true, ..OutObject::default() }) + } + None => decode_object(o, &format!("objects[{i}]"), &mut anchors), + } + }) + .collect::>>()?; + let mut missing: Vec = read_only.keys().filter(|id| !returned.contains(id)).copied().collect(); + missing.sort(); + if let Some(id) = missing.first() { + return Err(out_err( + &format!("objects (id {})", id.0), + "a read-only object was left out: return it, as {\"id\": …} alone or as it was given", + )); + } + Ok(out) +} + +/// Checks entry `o` of a filter's output for a read-only object it was `given`: `{"id": …}` alone, +/// or `given` itself, numbers compared by value. Otherwise the error names the first field that +/// differs (`text.runs[0].text`): a field the entry gives before one it leaves out. +fn check_unchanged(o: &Value, given: &Value, at: &str) -> Result<()> { + if o.as_object().is_some_and(|m| m.len() == 1) { + return Ok(()); + } + let changed = match (o, given) { + (Value::Object(x), Value::Object(y)) => { + let mut keys: Vec<&String> = x.keys().collect(); + keys.sort(); + keys.into_iter().find_map(|k| match y.get(k) { + Some(q) => first_difference(&x[k], q, k), + None => Some(k.clone()), + }) + } + _ => None, + }; + match changed.or_else(|| first_difference(o, given, "")) { + None => Ok(()), + Some(field) => Err(out_err(at, format!("read-only field '{field}' changed"))), + } +} + +/// The path of the first place `a` and `b` differ (`""`: they differ as a whole), `None` when they +/// are the same JSON with numbers compared by value. +fn first_difference(a: &Value, b: &Value, at: &str) -> Option { + let key = |k: &str| if at.is_empty() { k.to_string() } else { format!("{at}.{k}") }; + match (a, b) { + (Value::Number(x), Value::Number(y)) => (x.as_f64() != y.as_f64()).then(|| at.into()), + (Value::Object(x), Value::Object(y)) => { + let mut keys: Vec<&String> = x.keys().chain(y.keys()).collect(); + keys.sort(); + keys.dedup(); + keys.into_iter().find_map(|k| match (x.get(k), y.get(k)) { + (Some(p), Some(q)) => first_difference(p, q, &key(k)), + _ => Some(key(k)), + }) + } + (Value::Array(x), Value::Array(y)) => { + if x.len() != y.len() { + return Some(at.into()); + } + x.iter().zip(y).enumerate().find_map(|(i, (p, q))| first_difference(p, q, &format!("{at}[{i}]"))) + } + (a, b) => (a != b).then(|| at.into()), + } } /// The decline in a plug-in's output `v`, `{"error": "…"}`: its message as the user reads it, @@ -625,22 +868,30 @@ pub fn selectable(n: &Node) -> bool { /// guides too) and clipping paths are left out (so are paths inside compound paths, blends and /// other live objects). pub fn filter_targets(doc: &Document, selected: &[NodeId]) -> Vec { - fn visit(list: &[Arc], sel: &HashSet, inside: bool, out: &mut Vec) { - for n in list { + filter_targets_with(doc, selected, &[]) +} + +/// [`filter_targets`] for a filter that asks for `kinds` too: type, images and symbol instances of +/// those kinds join the paths in paint order, under the same rules (type that clips a clip group is +/// left out, and so is anything inside a symbol, a placed document or a live object). +pub fn filter_targets_with(doc: &Document, selected: &[NodeId], kinds: &[InputKind]) -> Vec { + fn visit(list: &[Arc], sel: &HashSet, inside: bool, clips: bool, kinds: &[InputKind], out: &mut Vec) { + for (i, n) in list.iter().enumerate() { if !n.visible || n.locked { continue; } let picked = inside || sel.contains(&n.id); match &n.kind { NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. } if picked && !is_guide(n) => out.push(n.id), - NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => visit(children, sel, picked, out), + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => visit(children, sel, picked, n.clips(), kinds, out), + _ if picked && !(clips && i == 0) && input_kind(n).is_some_and(|k| kinds.contains(&k)) => out.push(n.id), _ => {} } } } let sel: HashSet = selected.iter().copied().collect(); let mut out = vec![]; - visit(&doc.layers, &sel, false, &mut out); + visit(&doc.layers, &sel, false, false, kinds, &mut out); out } @@ -740,7 +991,14 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec, let why = if wanted.contains(&id) { "is listed twice" } else { "is not the id of an input object" }; out_err(&at, format!("{} {why}", id.0)) })?; - write(doc, &mut n, o, &at, admit)?; + // Type, images and symbol instances are only read: writing one would make it a path. + if input_kind(&n).is_some() && !o.kept { + return Err(out_err(&at, format!("{} is read-only", id.0))); + } + let kept = o.kept; + if !kept { + write(doc, &mut n, o, &at, admit)?; + } if !run.is_empty() { let nodes = std::mem::take(&mut run); match prev { @@ -748,7 +1006,9 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec, None => plan.before.insert(id, nodes), }; } - plan.replace.insert(id, n); + if !kept { + plan.replace.insert(id, n); + } prev = Some(id); result.push(id); } @@ -774,7 +1034,10 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec, None => plan.top = Some((parent, run)), } } - // Input objects the output left out are deleted. + // Input objects the output left out are deleted, except read-only ones, which it must return. + if let Some(n) = originals.values().filter(|n| input_kind(n).is_some()).min_by_key(|n| n.id) { + return Err(out_err(&format!("objects (id {})", n.id.0), "a read-only object was left out")); + } plan.delete = originals.into_keys().collect(); if let Some(list) = plan.list(None, &doc.layers) { doc.layers = list; diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs index 74219a31b..48b0a38b3 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -310,6 +310,244 @@ fn bad_stroke_styles_and_effect_records_are_refused() { } } +/// A `w` × `h` PNG whose pixels vary with their column, half transparent. +fn png(w: u32, h: u32) -> Vec { + let img = image::RgbaImage::from_fn(w, h, |x, y| image::Rgba([(x * 100) as u8, (y * 7) as u8, 30, 128])); + let mut out = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// An image object `w` × `h` pixels showing blob `key`, linked to `link`. +fn image_node(d: &mut Document, key: &str, (w, h): (u32, u32), link: Option<&str>) -> Node { + let im = vectorcraft_doc::ImageObject { + key: key.into(), + width: w, + height: h, + xf: Affine::new([2.0, 0.0, 0.0, 2.0, 40.0, 60.0]), + link: link.map(vectorcraft_doc::LinkInfo::new), + placement: Default::default(), + pixels: None, + }; + Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))) +} + +/// [`doc`] with, above its paths: point type "T" in two paragraphs, an embedded 2 × 1 image "E", a +/// linked image "L", an instance "S" of symbol "Star" and an instance "G" of a symbol the document +/// doesn't have. Returns those five too. +fn mixed() -> (Document, Vec, NodeId, [NodeId; 5]) { + use vectorcraft_doc::{CharStyle, ParaStyle, Symbol, TextObject}; + let (mut d, ids, layer) = doc(); + let mut t = TextObject::point(Point::new(5.0, 50.0), "Hi\nthere", CharStyle::default()); + t.set_paragraph_styles(vec![ParaStyle::default(), ParaStyle { space_before: 6.0, ..Default::default() }]); + let text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + d.images.insert("e".into(), ImageBlob::png(png(2, 1))); + d.images.insert("l".into(), ImageBlob::png(png(4, 4))); + let embedded = image_node(&mut d, "e", (2, 1), None); + let linked = image_node(&mut d, "l", (4, 4), Some("C:/private/photo.png")); + let art = Arc::new(Node::path(d.alloc_id(), square(0.0), Appearance::default())); + d.symbols.push(Symbol { name: "Star".into(), art, dynamic: false }); + let star = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Star".into(), xf: Affine::translate((100.0, 0.0)) }); + let ghost = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Gone".into(), xf: Affine::IDENTITY }); + let mut extra = [NodeId(0); 5]; + for (slot, (mut n, name)) in extra.iter_mut().zip([text, embedded, linked, star, ghost].into_iter().zip(["T", "E", "L", "S", "G"])) { + n.name = Some(name.into()); + *slot = d.insert(Some(layer), usize::MAX, n).unwrap(); + } + (d, ids, layer, extra) +} + +/// A filter that doesn't list `inputs` is given what it was before: the selected paths alone, in +/// the same bytes. One that lists kinds is given those objects too, in paint order, under the same +/// rules (hidden and locked ones left out, selected ones or ones in a selected layer). +#[test] +fn legacy_filter_input_bytes_are_unchanged() { + let (mut d, ids, layer, [text, embedded, linked, star, ghost]) = mixed(); + assert_eq!(filter_targets(&d, &[layer]), ids); + assert_eq!(filter_targets_with(&d, &[layer], &[]), ids); + let every: Vec = ids.iter().copied().chain([text, embedded, linked, star, ghost]).collect(); + let (bytes, read_only) = encode_input_with(&d, &every, &[]); + assert_eq!(bytes, encode_input(&d, &ids), "the other objects are left out"); + assert!(read_only.is_empty()); + let all = filter_targets_with(&d, &[layer], &InputKind::ALL); + assert_eq!(all, every); + let images: Vec = ids.iter().copied().chain([embedded, linked]).collect(); + assert_eq!(filter_targets_with(&d, &[layer], &[InputKind::Image]), images); + assert_eq!(filter_targets_with(&d, &[star, ids[0]], &InputKind::ALL), [ids[0], star], "selected directly"); + d.node_mut(text).unwrap().visible = false; + d.node_mut(star).unwrap().locked = true; + assert_eq!(filter_targets_with(&d, &[layer], &InputKind::ALL), [&ids[..], &[embedded, linked, ghost]].concat()); + let (bytes, read_only) = encode_input_with(&d, &all, &[InputKind::SymbolInstance]); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + let types: Vec<&str> = v["objects"].as_array().unwrap().iter().map(|o| o["type"].as_str().unwrap()).collect(); + assert_eq!(types, ["path", "path", "path", "symbolInstance", "symbolInstance"]); + assert_eq!(read_only.len(), 2); +} + +/// Type comes with its runs and their character attributes, one paragraph style per paragraph, its +/// kind and transform; type past [`MAX_INPUT_TEXT`] or [`MAX_INPUT_RUNS`] comes without text. +#[test] +fn declared_type_input_has_bounded_runs_and_paragraph_styles() { + use vectorcraft_doc::{CharStyle, ParaStyle, TextRun}; + let (mut d, _, _, [text, ..]) = mixed(); + let o = encode_read_only(&d, d.node(text).unwrap()).unwrap(); + assert_eq!((&o["type"], &o["name"], &o["truncated"]), (&json!("type"), &json!("T"), &json!(false))); + assert_eq!(o["transform"], json!([1.0, 0.0, 0.0, 1.0, 5.0, 50.0])); + assert_eq!((&o["text"]["kind"], &o["text"]["vertical"]), (&json!("point"), &json!(false))); + assert_eq!(o["text"]["runs"], json!([{"text": "Hi\nthere", "style": serde_json::to_value(CharStyle::default()).unwrap()}])); + let spaced = ParaStyle { space_before: 6.0, ..Default::default() }; + assert_eq!(o["text"]["paragraphs"], json!([serde_json::to_value(ParaStyle::default()).unwrap(), serde_json::to_value(spaced).unwrap()])); + assert!(o.get("path").is_none() && o.get("fills").is_none(), "not made into outlines"); + let set_runs = |d: &mut Document, runs: Vec| { + if let NodeKind::Text(t) = &mut d.node_mut(text).unwrap().kind { + t.runs = runs; + t.paras.clear(); + } + }; + let run = |s: String| TextRun::new(s, CharStyle::default()); + for (runs, truncated) in [ + (vec![run("x".repeat(MAX_INPUT_TEXT))], false), + (vec![run("x".repeat(MAX_INPUT_TEXT - 1)), run("é".into())], true), + (vec![run("\n".repeat(MAX_INPUT_RUNS - 1))], false), + (vec![run("\n".repeat(MAX_INPUT_RUNS))], true), + ((0..MAX_INPUT_RUNS).map(|_| run("a".into())).collect(), false), + ((0..=MAX_INPUT_RUNS).map(|_| run("a".into())).collect(), true), + ] { + let n = runs.len(); + set_runs(&mut d, runs); + let o = encode_read_only(&d, d.node(text).unwrap()).unwrap(); + assert_eq!(o["truncated"], json!(truncated), "{n} runs"); + let given = o["text"]["runs"].as_array().unwrap().len(); + assert_eq!((given, o["text"]["paragraphs"].as_array().unwrap().is_empty()), (if truncated { 0 } else { n }, truncated), "{n} runs"); + } +} + +/// An embedded image comes with its pixels when they are small; a linked image never does, and +/// nothing of its file's path is given. The color mode is read from the image. +#[test] +fn declared_embedded_and_linked_images_have_distinct_metadata_and_pixel_omission() { + let (mut d, _, layer, [_, embedded, linked, ..]) = mixed(); + let e = encode_read_only(&d, d.node(embedded).unwrap()).unwrap(); + assert_eq!((&e["type"], &e["pixelSize"], &e["colorMode"]), (&json!("image"), &json!([2, 1]), &json!("rgb"))); + assert_eq!((&e["linked"], &e["pixelsOmitted"], &e["transform"]), (&json!(false), &json!(false), &json!([2.0, 0.0, 0.0, 2.0, 40.0, 60.0]))); + let pixels = craft_vectorcraft_format::base64::decode(e["pixelsBase64"].as_str().unwrap()).unwrap(); + assert_eq!(pixels, [0, 0, 30, 128, 100, 0, 30, 128], "straight RGBA, row by row"); + let l = encode_read_only(&d, d.node(linked).unwrap()).unwrap(); + assert_eq!((&l["linked"], &l["pixelsOmitted"], &l["pixelSize"]), (&json!(true), &json!(true), &json!([4, 4]))); + assert!(l.get("pixelsBase64").is_none()); + assert!(!l.to_string().contains("private"), "the link's path isn't given: {l}"); + // Past the limit (443 × 443 fits it; 444 × 443 doesn't), and pixels of another size, are omitted. + for ((w, h), omitted) in [((443, 443), false), ((444, 443), true)] { + d.images.insert("big".into(), ImageBlob::png(png(w, h))); + let n = image_node(&mut d, "big", (w, h), None); + let id = d.insert(Some(layer), usize::MAX, n).unwrap(); + let o = encode_read_only(&d, d.node(id).unwrap()).unwrap(); + assert_eq!((&o["pixelsOmitted"], o.get("pixelsBase64").is_some()), (&json!(omitted), !omitted), "{w} × {h}"); + } + let n = image_node(&mut d, "e", (3, 1), None); + let id = d.insert(Some(layer), usize::MAX, n).unwrap(); + assert_eq!(encode_read_only(&d, d.node(id).unwrap()).unwrap()["pixelsOmitted"], json!(true), "not the size it says"); + // Gray pixels, and data that isn't an image. + let mut gray = vec![]; + image::GrayImage::from_pixel(2, 2, image::Luma([90])).write_to(&mut std::io::Cursor::new(&mut gray), image::ImageFormat::Png).unwrap(); + for (blob, mode) in [(ImageBlob::png(gray), "gray"), (ImageBlob::png(b"not an image".to_vec()), "unknown")] { + d.images.insert("x".into(), blob); + let n = image_node(&mut d, "x", (2, 2), None); + let id = d.insert(Some(layer), usize::MAX, n).unwrap(); + assert_eq!(encode_read_only(&d, d.node(id).unwrap()).unwrap()["colorMode"], json!(mode)); + } +} + +/// A symbol instance comes with the symbol it shows and its transform, never its art; one whose +/// symbol the document lacks says so. +#[test] +fn declared_symbol_instance_keeps_identity_and_transform() { + let (d, _, _, [.., star, ghost]) = mixed(); + let s = encode_read_only(&d, d.node(star).unwrap()).unwrap(); + assert_eq!((&s["type"], &s["symbolId"], &s["overrides"]), (&json!("symbolInstance"), &json!("Star"), &json!({}))); + assert_eq!(s["transform"], json!([1.0, 0.0, 0.0, 1.0, 100.0, 0.0])); + assert!(s.get("missing").is_none() && s.get("path").is_none()); + let g = encode_read_only(&d, d.node(ghost).unwrap()).unwrap(); + assert_eq!((&g["symbolId"], &g["missing"]), (&json!("Gone"), &json!(true))); +} + +/// A filter given read-only objects returns each one as `{"id": …}` or exactly as given (numbers by +/// value), and they stay as they are, new objects listed after one going just above it. Any change, +/// an object left out or listed twice, and a new object of such a kind are refused before anything +/// changes. +#[test] +fn read_only_kind_field_and_omission_refuse() { + let (mut d, ids, layer, [text, embedded, linked, star, ghost]) = mixed(); + let targets = filter_targets_with(&d, &[layer], &InputKind::ALL); + let (bytes, read_only) = encode_input_with(&d, &targets, &InputKind::ALL); + assert_eq!(read_only.len(), 5); + let input: Value = serde_json::from_slice(&bytes).unwrap(); + let before = d.clone(); + let out = decode_output_with(&bytes, &read_only).unwrap(); + assert_eq!(out.objects.iter().filter(|o| o.kept).count(), 5); + apply_output(&mut d, &targets, out.objects, None, &keep).unwrap(); + assert_eq!(d, before, "an exact echo changes nothing"); + // A filter that doesn't list `inputs` could never hand them back. + assert!(decode_output(&bytes).is_err()); + // Numbers written another way are the same numbers. + let mut echo = input.clone(); + echo["objects"][3]["transform"] = json!([1, 0, 0, 1, 5, 50]); + assert!(decode_output_with(echo.to_string().as_bytes(), &read_only).is_ok()); + // `{"id"}` alone keeps it; the paths left out are deleted and a new path goes above the type. + let ro = |id: NodeId| json!({"id": id.0}); + let out = json!({"objects": [ro(text), {"name": "new", "path": square(0.0)}, ro(embedded), ro(linked), ro(star), ro(ghost)]}); + let made = apply_output(&mut d, &targets, decode_output_with(out.to_string().as_bytes(), &read_only).unwrap().objects, None, &keep).unwrap(); + assert_eq!(names(&d, layer), ["?", "?", "T", "new", "E", "L", "S", "G"]); + assert_eq!(made.len(), 6); + assert!(ids.iter().all(|id| d.node(*id).is_none())); + assert_eq!(d.node(text), before.node(text), "the type is as it was"); + // Refused, naming the object and the field. + let t = text.0; + let changed = |f: &dyn Fn(&mut Value)| { + let mut v = input.clone(); + f(&mut v); + v + }; + for (out, says) in [ + ( + changed(&|v| v["objects"][3]["text"]["runs"][0]["text"] = json!("Bye")), + format!("objects[3] (id {t}): read-only field 'text.runs[0].text' changed"), + ), + (changed(&|v| v["objects"][3]["type"] = json!("path")), format!("(id {t}): read-only field 'type' changed")), + (changed(&|v| v["objects"][3]["opacity"] = json!(0.5)), format!("(id {t}): read-only field 'opacity' changed")), + (changed(&|v| v["objects"][3]["transform"][4] = json!(6)), format!("(id {t}): read-only field 'transform[4]' changed")), + ( + changed(&|v| { + v["objects"][4].as_object_mut().unwrap().remove("pixelsBase64"); + }), + format!("(id {}): read-only field 'pixelsBase64' changed", embedded.0), + ), + (changed(&|v| v["objects"][6]["symbolId"] = json!("Moon")), format!("(id {}): read-only field 'symbolId' changed", star.0)), + ( + changed(&|v| { + v["objects"].as_array_mut().unwrap().remove(3); + }), + format!("objects (id {t}): a read-only object was left out"), + ), + (changed(&|v| v["objects"].as_array_mut().unwrap().push(json!({"id": ghost.0}))), format!("objects[8] (id {}): is listed twice", ghost.0)), + ( + changed(&|v| v["objects"].as_array_mut().unwrap().push(json!({"type": "type", "path": square(0.0)}))), + "`type` must be \"path\" or \"compound\"".to_string(), + ), + ] { + let e = decode_output_with(out.to_string().as_bytes(), &read_only).unwrap_err().to_string(); + assert!(e.contains(&says), "{says}: {e}"); + } + // Writing one or leaving one out is refused when applying too, whatever decoded the output. + let mut d = before.clone(); + let write = vec![OutObject { id: Some(text), name: Some("Renamed".into()), ..OutObject::default() }]; + let e = apply_output(&mut d, &[text], write, None, &keep).unwrap_err().to_string(); + assert!(e.contains("is read-only"), "{e}"); + let e = apply_output(&mut d, &[text], vec![], None, &keep).unwrap_err().to_string(); + assert!(e.contains("a read-only object was left out"), "{e}"); + assert_eq!(d, before); +} + /// A filter's `documentData`: absent keeps what the plug-in keeps, `null` forgets it, any other JSON /// of at most [`MAX_DOCUMENT_DATA`] bytes is kept, and more refuses the whole output. #[test] diff --git a/crates/plugins/src/permissions.rs b/crates/plugins/src/permissions.rs index 083c78a04..c5959b8c0 100644 --- a/crates/plugins/src/permissions.rs +++ b/crates/plugins/src/permissions.rs @@ -20,6 +20,9 @@ pub const API_LEVEL: u32 = 2; /// permission they need. pub const PROGRESS_API: u32 = 2; +/// The API level that adds a filter's `inputs` ([`crate::InputKind`]). +pub const INPUTS_API: u32 = 2; + /// Most permissions a manifest may list. pub const MAX_PERMISSIONS: usize = 16; diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 51959c320..bc17b6d1b 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -627,6 +627,7 @@ impl PluginIntField { author: String::new(), api: 1, permissions: None, + inputs: Vec::new(), params: vec![("seed".into(), vectorcraft_plugins::ParamSpec::Int { min, max, default: 0 }.into())], extensions: Vec::new(), budget: Default::default(), diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index ea246b35c..a58a6c7ae 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5864,6 +5864,11 @@ digits Malayalam Malaiàlam @msg at most {MAX_PERMISSIONS} `permissions` com a màxim {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` el permís `{shown}` no s’aplica a un complement del tipus `{_1}` @msg permission `{shown}` is listed twice el permís `{shown}` apareix dues vegades +@msg only a filter declares `inputs` només un filtre declara `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` anomena un tipus d’objecte desconegut `{shown}`: feu servir type, image o symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` inclou `{shown}` dues vegades +@msg `inputs` needs `api` 2 or later `inputs` requereix `api` 2 o posterior +@msg `inputs` needs the permission `doc.read` `inputs` requereix el permís `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it el permís `{shown}` està reservat per a una API de complements posterior; aquest amfitrió no el concedeix @msg unknown permission `{shown}` permís desconegut `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» demana els permisos {asked}: instal·la’l amb un `grant` que els enumeri quan l’usuari hi estigui d’acord diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7e51de244..7f1ab772a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5844,6 +5844,11 @@ axis Y Axis osa Y @msg at most {MAX_PERMISSIONS} `permissions` nejvýše {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` oprávnění `{shown}` se nevztahuje na zásuvný modul druhu `{_1}` @msg permission `{shown}` is listed twice oprávnění `{shown}` je uvedeno dvakrát +@msg only a filter declares `inputs` `inputs` deklaruje pouze filtr +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` uvádí neznámý druh objektu `{shown}`: použijte type, image nebo symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` uvádí `{shown}` dvakrát +@msg `inputs` needs `api` 2 or later `inputs` vyžaduje `api` 2 nebo vyšší +@msg `inputs` needs the permission `doc.read` `inputs` vyžaduje oprávnění `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it oprávnění `{shown}` je vyhrazeno pro pozdější API zásuvných modulů; tento hostitel ho neuděluje @msg unknown permission `{shown}` neznámé oprávnění `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ žádá o oprávnění {asked}: nainstalujte ho s `grant`, který je uvádí, jakmile uživatel souhlasí diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7d0e48fb8..6c7453f51 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5426,6 +5426,11 @@ axis Y Axis Y-Achse @msg at most {MAX_PERMISSIONS} `permissions` höchstens {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` die Berechtigung `{shown}` gilt nicht für ein Plug-in der Art `{_1}` @msg permission `{shown}` is listed twice die Berechtigung `{shown}` ist doppelt aufgeführt +@msg only a filter declares `inputs` nur ein Filter deklariert `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` nennt eine unbekannte Objektart `{shown}`: verwenden Sie type, image oder symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` führt `{shown}` doppelt auf +@msg `inputs` needs `api` 2 or later `inputs` erfordert `api` 2 oder höher +@msg `inputs` needs the permission `doc.read` `inputs` erfordert die Berechtigung `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it die Berechtigung `{shown}` ist für eine spätere Plug-in-API reserviert; dieser Host gewährt sie nicht @msg unknown permission `{shown}` unbekannte Berechtigung `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ verlangt die Berechtigungen {asked}: Installieren Sie es mit `grant`, das sie aufführt, sobald der Benutzer zustimmt diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index be001e2fb..036c75f2e 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5795,6 +5795,11 @@ axis Y Axis Άξονας Y @msg at most {MAX_PERMISSIONS} `permissions` το πολύ {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` το δικαίωμα `{shown}` δεν ισχύει για πρόσθετο τύπου `{_1}` @msg permission `{shown}` is listed twice το δικαίωμα `{shown}` αναφέρεται δύο φορές +@msg only a filter declares `inputs` μόνο ένα φίλτρο δηλώνει `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance το `inputs` αναφέρει άγνωστο είδος αντικειμένου `{shown}`: χρησιμοποιήστε type, image ή symbolInstance +@msg `inputs` lists `{shown}` twice το `inputs` αναφέρει το `{shown}` δύο φορές +@msg `inputs` needs `api` 2 or later το `inputs` απαιτεί `api` 2 ή νεότερο +@msg `inputs` needs the permission `doc.read` το `inputs` απαιτεί το δικαίωμα `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it το δικαίωμα `{shown}` είναι δεσμευμένο για μεταγενέστερο API προσθέτων· αυτός ο κεντρικός υπολογιστής δεν το παραχωρεί @msg unknown permission `{shown}` άγνωστο δικαίωμα `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees το «{name}» ζητά τα δικαιώματα {asked}: εγκαταστήστε το με ένα `grant` που τα απαριθμεί μόλις συμφωνήσει ο χρήστης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 930ecf090..578a8ae2f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5785,6 +5785,11 @@ axis Y Axis Eje Y @msg at most {MAX_PERMISSIONS} `permissions` como máximo {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` el permiso `{shown}` no se aplica a un plugin de tipo `{_1}` @msg permission `{shown}` is listed twice el permiso `{shown}` aparece dos veces +@msg only a filter declares `inputs` solo un filtro declara `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` nombra un tipo de objeto desconocido `{shown}`: use type, image o symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` incluye `{shown}` dos veces +@msg `inputs` needs `api` 2 or later `inputs` requiere `api` 2 o posterior +@msg `inputs` needs the permission `doc.read` `inputs` requiere el permiso `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it el permiso `{shown}` está reservado para una API de plugins posterior; este host no lo concede @msg unknown permission `{shown}` permiso desconocido `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pide los permisos {asked}: instálalo con un `grant` que los incluya cuando el usuario lo acepte diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 99594975d..ef71d5371 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5874,6 +5874,11 @@ axis Y Axis Y-akseli @msg at most {MAX_PERMISSIONS} `permissions` enintään {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` käyttöoikeus `{shown}` ei koske lajin `{_1}` laajennusta @msg permission `{shown}` is listed twice käyttöoikeus `{shown}` on lueteltu kahdesti +@msg only a filter declares `inputs` vain suodatin voi määrittää `inputs`-avaimen +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` nimeää tuntemattoman objektilajin `{shown}`: käytä arvoa type, image tai symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` luettelee kohteen `{shown}` kahdesti +@msg `inputs` needs `api` 2 or later `inputs` edellyttää `api`-tasoa 2 tai uudempaa +@msg `inputs` needs the permission `doc.read` `inputs` edellyttää käyttöoikeutta `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it käyttöoikeus `{shown}` on varattu myöhemmälle laajennus-API:lle; tämä isäntä ei myönnä sitä @msg unknown permission `{shown}` tuntematon käyttöoikeus `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” pyytää käyttöoikeuksia {asked}: asenna se `grant`-luettelolla, jossa ne ovat, kun käyttäjä suostuu diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1aebc348d..21173799a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5840,6 +5840,11 @@ axis Y Axis Axe Y @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` l’autorisation `{shown}` ne s’applique pas à un module externe de type `{_1}` @msg permission `{shown}` is listed twice l’autorisation `{shown}` figure deux fois @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it l’autorisation `{shown}` est réservée à une future API de module externe ; cet hôte ne l’accorde pas +@msg only a filter declares `inputs` seul un filtre déclare `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` désigne un type d’objet inconnu `{shown}` : utilisez type, image ou symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` mentionne `{shown}` deux fois +@msg `inputs` needs `api` 2 or later `inputs` nécessite `api` 2 ou plus +@msg `inputs` needs the permission `doc.read` `inputs` nécessite l’autorisation `doc.read` @msg unknown permission `{shown}` autorisation inconnue `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees « {name} » demande les autorisations {asked} : installez-le avec `grant` qui les liste une fois que l’utilisateur l’accepte @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed « {name} » n’a pas reçu `{p}`, dont sa sortie a besoin : rien n’a été modifié diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 0f212f4fd..806986bd6 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5236,6 +5236,11 @@ @msg at most {MAX_PERMISSIONS} `permissions` अधिकतम {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` अनुमति `{shown}` `{_1}` प्रकार के प्लग-इन पर लागू नहीं होती @msg permission `{shown}` is listed twice अनुमति `{shown}` दो बार सूचीबद्ध है +@msg only a filter declares `inputs` केवल फ़िल्टर `inputs` घोषित करता है +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` में अज्ञात ऑब्जेक्ट प्रकार `{shown}` है: type, image या symbolInstance का उपयोग करें +@msg `inputs` lists `{shown}` twice `inputs` में `{shown}` दो बार सूचीबद्ध है +@msg `inputs` needs `api` 2 or later `inputs` के लिए `api` 2 या बाद का स्तर आवश्यक है +@msg `inputs` needs the permission `doc.read` `inputs` के लिए अनुमति `doc.read` आवश्यक है @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it अनुमति `{shown}` बाद के प्लग-इन API के लिए आरक्षित है; यह होस्ट इसे नहीं देता @msg unknown permission `{shown}` अज्ञात अनुमति `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” अनुमतियां {asked} मांगता है: उपयोगकर्ता की सहमति मिलने पर इसे उन्हें सूचीबद्ध करने वाले `grant` के साथ इंस्टॉल करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a400c495f..adfe48363 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5827,6 +5827,11 @@ axis Y Axis Y tengely @msg at most {MAX_PERMISSIONS} `permissions` legfeljebb {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` a(z) `{shown}` engedély nem vonatkozik `{_1}` fajtájú bővítményre @msg permission `{shown}` is listed twice a(z) `{shown}` engedély kétszer szerepel +@msg only a filter declares `inputs` `inputs` kulcsot csak szűrő adhat meg +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance az `inputs` ismeretlen objektumfajtát nevez meg: `{shown}`; használja a type, image vagy symbolInstance értéket +@msg `inputs` lists `{shown}` twice az `inputs` kétszer sorolja fel ezt: `{shown}` +@msg `inputs` needs `api` 2 or later az `inputs` használatához `api` 2 vagy újabb szükséges +@msg `inputs` needs the permission `doc.read` az `inputs` használatához a(z) `doc.read` engedély szükséges @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it a(z) `{shown}` engedély egy későbbi bővítmény-API számára van fenntartva; ez a gazdaprogram nem adja meg @msg unknown permission `{shown}` ismeretlen engedély: `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees a(z) „{name}” a következő engedélyeket kéri: {asked}; telepítsd egy őket felsoroló `grant` értékkel, ha a felhasználó beleegyezik diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index b19e01f96..fcf5cef1f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5460,6 +5460,11 @@ axis Y Axis Asse Y @msg at most {MAX_PERMISSIONS} `permissions` al massimo {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` l’autorizzazione `{shown}` non si applica a un plug-in di tipo `{_1}` @msg permission `{shown}` is listed twice l’autorizzazione `{shown}` è elencata due volte +@msg only a filter declares `inputs` solo un filtro dichiara `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` indica un tipo di oggetto sconosciuto `{shown}`: usa type, image o symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` elenca `{shown}` due volte +@msg `inputs` needs `api` 2 or later `inputs` richiede `api` 2 o successivo +@msg `inputs` needs the permission `doc.read` `inputs` richiede l’autorizzazione `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it l’autorizzazione `{shown}` è riservata a un’API dei plug-in successiva; questo host non la concede @msg unknown permission `{shown}` autorizzazione sconosciuta `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” chiede le autorizzazioni {asked}: installalo con un `grant` che le elenca quando l’utente acconsente diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 77d50e0a8..1407ca8b7 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5525,6 +5525,11 @@ axis Y Axis Y 軸 @msg at most {MAX_PERMISSIONS} `permissions` `permissions` は最大 {MAX_PERMISSIONS} 個です @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` 権限 `{shown}` は種類 `{_1}` のプラグインには適用されません @msg permission `{shown}` is listed twice 権限 `{shown}` が 2 回指定されています +@msg only a filter declares `inputs` `inputs` を宣言できるのはフィルターだけです +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` に不明なオブジェクトの種類 `{shown}` があります。type、image、symbolInstance のいずれかを使ってください +@msg `inputs` lists `{shown}` twice `inputs` に `{shown}` が 2 回指定されています +@msg `inputs` needs `api` 2 or later `inputs` には `api` 2 以上が必要です +@msg `inputs` needs the permission `doc.read` `inputs` には権限 `doc.read` が必要です @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 権限 `{shown}` は今後のプラグイン API 用に予約されています。このホストはこの権限を付与しません @msg unknown permission `{shown}` 不明な権限 `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」は権限 {asked} を求めています。ユーザーが同意したら、それらを列挙した `grant` を指定してインストールしてください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c710ee3c5..649eeaf8e 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5767,6 +5767,11 @@ axis Y Axis Y ღერძი @msg at most {MAX_PERMISSIONS} `permissions` მაქსიმუმ {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` ნებართვა `{shown}` არ ეხება `{_1}` სახის პლაგინს @msg permission `{shown}` is listed twice ნებართვა `{shown}` ორჯერაა ჩამოთვლილი +@msg only a filter declares `inputs` `inputs`-ის გამოცხადება მხოლოდ ფილტრს შეუძლია +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` უცნობ ობიექტის სახეობას ასახელებს `{shown}`: გამოიყენეთ type, image ან symbolInstance +@msg `inputs` lists `{shown}` twice `inputs`-ში `{shown}` ორჯერაა ჩამოთვლილი +@msg `inputs` needs `api` 2 or later `inputs`-ს სჭირდება `api` 2 ან უფრო ახალი +@msg `inputs` needs the permission `doc.read` `inputs`-ს სჭირდება ნებართვა `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it ნებართვა `{shown}` დაჯავშნილია პლაგინების მომავალი API-სთვის; ეს ჰოსტი მას არ გასცემს @msg unknown permission `{shown}` უცნობი ნებართვა `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ ითხოვს ნებართვებს {asked}: მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `grant` პარამეტრით, რომელშიც ისინი ჩამოთვლილია diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index c0545a3da..88a5e6fee 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5421,6 +5421,11 @@ axis Y Axis Y축 @msg at most {MAX_PERMISSIONS} `permissions` `permissions`는 최대 {MAX_PERMISSIONS}개입니다 @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` `{shown}` 권한은 `{_1}` 종류의 플러그인에 적용되지 않습니다 @msg permission `{shown}` is listed twice `{shown}` 권한이 두 번 나열되어 있습니다 +@msg only a filter declares `inputs` 필터만 `inputs`를 선언할 수 있습니다 +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs`에 알 수 없는 개체 종류 `{shown}`이(가) 있습니다. type, image 또는 symbolInstance를 사용하세요 +@msg `inputs` lists `{shown}` twice `inputs`에 `{shown}`이(가) 두 번 나열되어 있습니다 +@msg `inputs` needs `api` 2 or later `inputs`에는 `api` 2 이상이 필요합니다 +@msg `inputs` needs the permission `doc.read` `inputs`에는 `doc.read` 권한이 필요합니다 @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it `{shown}` 권한은 이후 플러그인 API용으로 예약되어 있습니다. 이 호스트는 이 권한을 부여하지 않습니다 @msg unknown permission `{shown}` 알 수 없는 권한 `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”은(는) {asked} 권한을 요청합니다. 사용자가 동의하면 이를 나열한 `grant`로 설치하세요 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ff2b9bd6b..a788257b5 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5697,6 +5697,11 @@ axis Y Axis Oś Y @msg at most {MAX_PERMISSIONS} `permissions` najwyżej {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` uprawnienie `{shown}` nie dotyczy wtyczki rodzaju `{_1}` @msg permission `{shown}` is listed twice uprawnienie `{shown}` występuje dwa razy +@msg only a filter declares `inputs` tylko filtr deklaruje `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` wymienia nieznany rodzaj obiektu `{shown}`: użyj type, image lub symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` wymienia `{shown}` dwa razy +@msg `inputs` needs `api` 2 or later `inputs` wymaga `api` 2 lub nowszego +@msg `inputs` needs the permission `doc.read` `inputs` wymaga uprawnienia `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it uprawnienie `{shown}` jest zarezerwowane dla późniejszego API wtyczek; ten host go nie przyznaje @msg unknown permission `{shown}` nieznane uprawnienie `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}” prosi o uprawnienia {asked}: zainstaluj ją z `grant`, który je wymienia, gdy użytkownik się zgodzi diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3340597d8..a2dc3ad53 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5759,6 +5759,11 @@ axis Y Axis Eixo Y @msg at most {MAX_PERMISSIONS} `permissions` no máximo {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` a permissão `{shown}` não se aplica a um plug-in do tipo `{_1}` @msg permission `{shown}` is listed twice a permissão `{shown}` aparece duas vezes +@msg only a filter declares `inputs` somente um filtro declara `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` cita um tipo de objeto desconhecido `{shown}`: use type, image ou symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` lista `{shown}` duas vezes +@msg `inputs` needs `api` 2 or later `inputs` exige `api` 2 ou posterior +@msg `inputs` needs the permission `doc.read` `inputs` exige a permissão `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it a permissão `{shown}` está reservada para uma API de plug-in futura; este host não a concede @msg unknown permission `{shown}` permissão desconhecida `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pede as permissões {asked}: instale-o com `grant` listando-as depois que o usuário concordar diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3732558f7..3e0425623 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5455,6 +5455,11 @@ axis Y Axis Ось Y @msg at most {MAX_PERMISSIONS} `permissions` не более {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` разрешение `{shown}` не относится к модулю вида `{_1}` @msg permission `{shown}` is listed twice разрешение `{shown}` указано дважды +@msg only a filter declares `inputs` `inputs` может объявлять только фильтр +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance в `inputs` указан неизвестный вид объекта `{shown}`: используйте type, image или symbolInstance +@msg `inputs` lists `{shown}` twice в `inputs` `{shown}` указано дважды +@msg `inputs` needs `api` 2 or later для `inputs` нужен `api` 2 или выше +@msg `inputs` needs the permission `doc.read` для `inputs` нужно разрешение `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it разрешение `{shown}` зарезервировано для будущего API модулей; этот хост его не выдаёт @msg unknown permission `{shown}` неизвестное разрешение `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запрашивает разрешения {asked}: установите его с `grant`, где они перечислены, когда пользователь согласится diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 58ceab701..aaefbc72d 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5867,6 +5867,11 @@ axis Y Axis Y-axel @msg at most {MAX_PERMISSIONS} `permissions` högst {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` behörigheten `{shown}` gäller inte ett plugin av typen `{_1}` @msg permission `{shown}` is listed twice behörigheten `{shown}` står två gånger +@msg only a filter declares `inputs` bara ett filter anger `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` anger en okänd objekttyp `{shown}`: använd type, image eller symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` anger `{shown}` två gånger +@msg `inputs` needs `api` 2 or later `inputs` kräver `api` 2 eller senare +@msg `inputs` needs the permission `doc.read` `inputs` kräver behörigheten `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it behörigheten `{shown}` är reserverad för ett senare plugin-API; den här värden beviljar den inte @msg unknown permission `{shown}` okänd behörighet `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” begär behörigheterna {asked}: installera det med en `grant` som listar dem när användaren har godkänt diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 9385b30cc..c037a53ea 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5801,6 +5801,11 @@ axis Y Axis Y ekseni @msg at most {MAX_PERMISSIONS} `permissions` en fazla {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` `{shown}` izni `{_1}` türündeki bir eklentiye uygulanmaz @msg permission `{shown}` is listed twice `{shown}` izni iki kez listelenmiş +@msg only a filter declares `inputs` `inputs` yalnızca bir filtrede bildirilebilir +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` bilinmeyen bir nesne türü belirtiyor: `{shown}`; type, image veya symbolInstance kullanın +@msg `inputs` lists `{shown}` twice `inputs` `{shown}` değerini iki kez listeliyor +@msg `inputs` needs `api` 2 or later `inputs` için `api` 2 veya üstü gerekir +@msg `inputs` needs the permission `doc.read` `inputs` için `doc.read` izni gerekir @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it `{shown}` izni sonraki bir eklenti API’si için ayrılmıştır; bu ana bilgisayar bu izni vermez @msg unknown permission `{shown}` bilinmeyen izin `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” {asked} izinlerini istiyor: kullanıcı onayladıktan sonra bunları listeleyen bir `grant` ile yükleyin diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 1b96c235e..f66048c92 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5440,6 +5440,11 @@ axis Y Axis Вісь Y @msg at most {MAX_PERMISSIONS} `permissions` щонайбільше {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` дозвіл `{shown}` не стосується модуля виду `{_1}` @msg permission `{shown}` is listed twice дозвіл `{shown}` указано двічі +@msg only a filter declares `inputs` `inputs` може оголошувати лише фільтр +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance в `inputs` указано невідомий вид об’єкта `{shown}`: використовуйте type, image або symbolInstance +@msg `inputs` lists `{shown}` twice в `inputs` `{shown}` указано двічі +@msg `inputs` needs `api` 2 or later для `inputs` потрібен `api` 2 або новіший +@msg `inputs` needs the permission `doc.read` для `inputs` потрібен дозвіл `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it дозвіл `{shown}` зарезервовано для пізнішого API модулів; цей хост його не надає @msg unknown permission `{shown}` невідомий дозвіл `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запитує дозволи {asked}: установіть його з `grant`, де їх перелічено, коли користувач погодиться diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ce98bc978..fb8c36009 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5441,6 +5441,11 @@ axis Y Axis Trục Y @msg at most {MAX_PERMISSIONS} `permissions` tối đa {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` quyền `{shown}` không áp dụng cho plug-in loại `{_1}` @msg permission `{shown}` is listed twice quyền `{shown}` được liệt kê hai lần +@msg only a filter declares `inputs` chỉ bộ lọc mới khai báo `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` nêu một loại đối tượng không xác định `{shown}`: dùng type, image hoặc symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` liệt kê `{shown}` hai lần +@msg `inputs` needs `api` 2 or later `inputs` cần `api` 2 trở lên +@msg `inputs` needs the permission `doc.read` `inputs` cần quyền `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it quyền `{shown}` được dành cho một API plug-in sau này; máy chủ này không cấp quyền đó @msg unknown permission `{shown}` quyền không xác định `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” yêu cầu các quyền {asked}: hãy cài đặt với `grant` liệt kê chúng khi người dùng đồng ý diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a2cf40630..f449dc315 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5682,6 +5682,11 @@ axis Y Axis Y 轴 @msg at most {MAX_PERMISSIONS} `permissions` 最多 {MAX_PERMISSIONS} 个 `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` 权限 `{shown}` 不适用于 `{_1}` 类型的增效工具 @msg permission `{shown}` is listed twice 权限 `{shown}` 列出了两次 +@msg only a filter declares `inputs` 只有滤镜才能声明 `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` 列出了未知的对象类型 `{shown}`:请使用 type、image 或 symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` 列出了两次 `{shown}` +@msg `inputs` needs `api` 2 or later `inputs` 需要 `api` 2 或更高 +@msg `inputs` needs the permission `doc.read` `inputs` 需要权限 `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 权限 `{shown}` 保留给以后的增效工具 API;此主机不授予该权限 @msg unknown permission `{shown}` 未知权限 `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”需要权限 {asked}:请在用户同意后使用列出这些权限的 `grant` 安装 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9440e225f..e5b3b5641 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5478,6 +5478,11 @@ axis Y Axis Y 軸 @msg at most {MAX_PERMISSIONS} `permissions` 最多 {MAX_PERMISSIONS} 個 `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` 權限 `{shown}` 不適用於 `{_1}` 類型的增效模組 @msg permission `{shown}` is listed twice 權限 `{shown}` 列出了兩次 +@msg only a filter declares `inputs` 只有濾鏡才能宣告 `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` 列出了未知的物件類型 `{shown}`:請使用 type、image 或 symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` 列出了兩次 `{shown}` +@msg `inputs` needs `api` 2 or later `inputs` 需要 `api` 2 或更高 +@msg `inputs` needs the permission `doc.read` `inputs` 需要權限 `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 權限 `{shown}` 保留給日後的增效模組 API;此主機不授予該權限 @msg unknown permission `{shown}` 未知權限 `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」需要權限 {asked}:請在使用者同意後以列出這些權限的 `grant` 安裝 diff --git a/docs/mcp.md b/docs/mcp.md index 9220b1c62..d2606e316 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6086,7 +6086,10 @@ WebAssembly plug-ins ([plugins.md](plugins.md)) are reached through commands; th them again at startup (`plugin.remove` deletes them there): `plugin.install {path | dataBase64}` installs one, `plugin.list` / `plugin.info {id}` describe them (parameters, schema, defaults). An object filter runs on the selected paths and compound paths with `plugin.run {id, params}` (one -undo step; it may change, delete or add objects, and the result is selected). A live-effect plug-in is an effect with +undo step; it may change, delete or add objects, and the result is selected). A filter whose manifest lists `inputs` +(`type`, `image`, `symbolInstance`; `inputs` in its `plugin.list` entry) is also given the selected objects of those +kinds to read: its output returns each of them as `{"id": …}` or exactly as given, otherwise `plugin.run` returns an +error naming the object and the field, and the document and undo history are unchanged. A live-effect plug-in is an effect with 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 diff --git a/docs/plugins.md b/docs/plugins.md index 27a7b0f91..fb1c06a6e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -145,7 +145,10 @@ capability model proposed for PhotoCraft plug-ins `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. - `api` is the plug-in API level the manifest is written for: `1` (the default) or `2`, which adds the [host - imports](#host-imports-api-level-2). A higher one is refused. `plugin.list` and `plugin.info` give it as `api`. + imports](#host-imports-api-level-2) and a filter's `inputs` ([reading type, images and + symbols](#reading-type-images-and-symbols)). A higher one is refused. `plugin.list` and `plugin.info` give it as + `api`. `inputs` is not a permission: the objects it asks for are read under `doc.read`, which a filter that lists + `permissions` and `inputs` must list. - Parameter declarations are unchanged by permissions (`params`, below). Every run gets a **fresh instance**, so no state survives between calls. Traps, exhausted budgets, malformed @@ -237,6 +240,11 @@ still returns `1`. 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. +- `inputs` (filters at `api` 2 only): the kinds of object besides paths and compound paths the filter reads, any of + `"type"`, `"image"` and `"symbolInstance"`, each at most once (`"inputs": ["type", "image"]`). Left out or `[]`, the + filter gets paths and compound paths alone, as before. An unknown, repeated or wrongly typed entry, `inputs` on + another kind, below `api` 2, or with `permissions` that leave out `doc.read`, is refused when the plug-in is + installed, and so is `api` 2 on hosts from before it. - `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. @@ -276,8 +284,9 @@ parameters uses `label`, `unit`, `help` and `visibleWhen`, and lists the paramet `input` is UTF-8 JSON: `{"objects": [...]}`. An object filter gets the selected paths and compound paths in paint order (bottom first): the selected ones and those inside selected groups and layers; hidden, locked, guide (compound -guides too) and clipping paths are left out, and so are other kinds of object (type, images, symbols, live blends…), which a filter never -touches. With nothing selected the list is empty (a generator then adds its own objects). A live effect gets one +guides too) and clipping paths are left out, and so are other kinds of object (type, images, symbols, live blends…), +which a filter never touches unless its manifest lists them in `inputs` ([reading type, images and +symbols](#reading-type-images-and-symbols)). With nothing selected the list is empty (a generator then adds its own objects). A live effect gets one object: its geometry (`type`, `path`, `fillRule`, `bounds`) and its `fills` and `strokes`, as a filter gets them (hosts from before these give geometry only). The paints are read only: an effect may draw as wide as a stroke or use a fill's colour to decide its shape, but it changes only the geometry. @@ -323,6 +332,49 @@ type and `blob` but without their data, which follows the JSON in a saved file; `plugin.` for a live-effect plug-in), `params` and `visible`. Its fills' and strokes' own effects aren't listed. - `bounds`: `[x0, y0, x1, y1]`, the geometric bounds (read only). +### Reading type, images and symbols + +A filter whose manifest lists `inputs` also gets the selected objects of those kinds, in paint order among the paths +and under the same rules (hidden and locked ones, type that clips a clip group and anything inside a symbol, a placed +document or a live object are left out). They are never turned into outlines, rectangles or the symbol's art. Each +has `id`, `type`, `name` (when it has one), `bounds` (`null` until the object has been laid out or drawn) and +`transform`, `[a, b, c, d, e, f]` mapping its own space (text space, pixel space, the symbol's art) into the document. +In this example the character and paragraph styles are cut short; a filter gets every attribute: + +```json +{"objects": [ + {"id": 17, "type": "type", "bounds": [10, 38, 52, 52], "transform": [1, 0, 0, 1, 10, 50], + "text": {"kind": "point", "vertical": false, + "runs": [{"text": "Hello\nthere", "style": {"font_family": "Source Sans 3", "size": 12}}], + "paragraphs": [{"justify": "Left"}, {"justify": "Center"}]}, + "truncated": false}, + {"id": 18, "type": "image", "bounds": [0, 0, 4, 2], "transform": [2, 0, 0, 2, 0, 0], "pixelSize": [2, 1], + "colorMode": "rgb", "linked": false, "pixelsOmitted": false, "pixelsBase64": "AAAegGQAHoA="}, + {"id": 19, "type": "symbolInstance", "bounds": [100, 0, 110, 10], "transform": [1, 0, 0, 1, 100, 0], + "symbolId": "Star", "overrides": {}} +]} +``` + +- Type (`"type"`): `text.kind` is `"point"`, `"area"` or `"path"`; `runs` are its text runs with their character + attributes as the native file stores them (`inline` when a run is an inline graphic), and `paragraphs` holds one + paragraph style per paragraph (the paragraphs are split at `\n`). No font data or glyph outlines are given. Type + whose runs hold more than 256 KiB of UTF-8 text, or more than 4,096 runs or paragraphs, comes with `truncated: + true` and empty `runs` and `paragraphs`. +- An image (`"image"`): `pixelSize` is its width and height in pixels; `colorMode` is `"rgb"`, `"cmyk"`, `"gray"` or + `"unknown"`, read from the image itself. An embedded image whose pixels take at most 1 MiB in base64 (443 × 443 + pixels fit) has them in `pixelsBase64`: straight RGBA, 8 bits a channel, row by row from the top, and + `pixelsOmitted: false`. A larger one, and a linked image (`linked: true`), has `pixelsOmitted: true` and no pixels. + The host never reads a linked image's file for a filter and never gives its path. +- A symbol instance (`"symbolInstance"`): `symbolId` is the name of the symbol it shows, with `missing: true` when the + document has no symbol of that name. `overrides` is `{}`. + +These objects are read only. The output must list each of them exactly once, as `{"id": 17}` alone or exactly as it +was given (numbers compare by value, so `1` and `1.0` are the same). A changed or added field, one left out, an object +listed twice or left out, and a new object with one of these types are refused, and the document and undo history +stay as they were: `objects[1] (id 17): read-only field 'text.runs[0].text' changed`, `objects (id 17): a read-only +object was left out`. New objects listed after one of them go just above it. Their text and pixels count towards the +input's half of the memory cap, and an echo of them towards the 64 MiB output cap. + ### Parameters `params` points at `params_len` bytes of UTF-8 JSON: the validated parameters, plus `_context`: @@ -420,6 +472,6 @@ per change of their input, parameters or paints (results are cached), not per fr - Tool plug-ins (pointer events on the canvas, a guide line or rectangle, geometry back), panel plug-ins, importers (plug-in formats that open files). -- Live effects that change paint (they reshape geometry; an object filter recolours), and filters on type, images or - symbols. +- Live effects that change paint (they reshape geometry; an object filter recolours), and filters that change type, + images or symbol instances (a filter at `api` 2 reads them, see [`inputs`](#reading-type-images-and-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 31dc47a26..dab0ea86e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1488,6 +1488,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **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 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. +- **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs), images with their size, color mode and, when embedded and at most 1 MiB in base64, their RGBA pixels (never a linked file's), and symbol instances with their symbol and transform. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.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 66a01584aeb40cd2bec99a3a59f02f9fa01b1345 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 00:15:36 -0800 Subject: [PATCH 34/71] Plug-ins: inputs need a granted doc.read and stay within totals A filter manifest that lists `inputs` must now list `doc.read` in `permissions`, so installing it asks the user to grant them and the Additional Plug-ins Folder loads it only once granted. Left out, the manifest does not install. The pixels of all images in one filter input take at most 16 MiB of base64, and the runs and paragraphs of all its type at most 16 MiB of JSON. An image whose pixels do not fit what is left comes with `pixelsOmitted`, checked before it is decoded, and type that does not fit comes with `truncated`. The read-only objects are kept once and written into the input from there. A field a filter adds to a read-only object is named in the refusal with control and bidirectional characters escaped, cut to 64 characters. --- crates/engine/src/cmd/plugin.rs | 2 +- crates/engine/src/tests/plugins.rs | 16 +++- crates/engine/tests/plugin_fuzz.rs | 7 +- crates/plugins/src/manifest.rs | 24 +++-- crates/plugins/src/objects.rs | 136 ++++++++++++++++++++-------- crates/plugins/src/objects/tests.rs | 51 +++++++++++ docs/mcp.md | 8 +- docs/plugins.md | 19 ++-- docs/target-app-parity.md | 2 +- 9 files changed, 202 insertions(+), 63 deletions(-) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 316221488..f352bdac1 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -531,7 +531,7 @@ pub fn specs() -> Vec { "Run Plug-in", [], None, - "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. A filter whose manifest lists inputs (type, image, symbolInstance) is also given the selected objects of those kinds to read: its output returns each as {id} or exactly as given, or the run is refused and nothing changes. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}", + "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. A filter whose manifest lists inputs (type, image, symbolInstance; it lists doc.read in permissions, so plugin.install needs grant) is also given the selected objects of those kinds to read, with at most 16 MiB of pixels and 16 MiB of type runs per run (later ones come with pixelsOmitted or truncated): its output returns each as {id} or exactly as given, or the run is refused and nothing changes. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}", has_doc, run ), diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index ee67fe603..d80873ac8 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -663,16 +663,26 @@ fn a_filter_does_only_what_its_permissions_allow() { /// A filter whose manifest lists `inputs` is also given the selected type, read-only: an echo /// changes nothing, and an output that leaves the type out or changes it is refused with the /// document and undo history as they were, handed in from a run made elsewhere too. A filter -/// without `inputs` is not given the type and so never deletes it. +/// without `inputs` is not given the type and so never deletes it. Installing one that lists +/// `inputs` needs the user's grant. #[test] fn read_only_kind_field_and_omission_refuse_without_undo() { use cmd::plugin::FilterRun; let mut s = session(); let r = rect(&mut s, 10.0, "#ff0000"); let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 80, "text": "Hi"}))["id"].as_u64().unwrap()); - let reads = |id: &str| format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"inputs":["type"]}}"#); + let reads = + |id: &str| format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":["doc.read","doc.write"],"inputs":["type"]}}"#); + let install_wat = |s: &mut Session, src: &str| { + let data = vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap()); + run(s, "plugin.install", json!({"dataBase64": data, "grant": ["doc.read", "doc.write"]})) + }; let echo = "test.engine.inputs.echo"; - let listed = install_wat(&mut s, &tpl::module(&reads(echo), tpl::ECHO, "", 1)); + let module = tpl::module(&reads(echo), tpl::ECHO, "", 1); + let data = vectorcraft_format::base64_encode(&wat::parse_str(&module).unwrap()); + let e = s.execute("plugin.install", &json!({"dataBase64": data})).unwrap_err().to_string(); + assert!(e.contains("asks for the permissions doc.read, doc.write"), "{e}"); + let listed = install_wat(&mut s, &module); assert_eq!(listed["inputs"], json!(["type"])); let p = |id: &str| json!({"id": id, "ids": [r.0, t.0]}); let before = s.doc().unwrap().doc.clone(); diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index 5dc2e2a5e..089e6255e 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -365,7 +365,8 @@ proptest! { /// Manifests with hostile `inputs` (unknown, repeated and oddly cased kinds, long lists, junk of /// every type) with any `api`, kind and permissions parse to an error or to a filter at `api` 2 - /// or later asking for each kind once. One that installs and echoes its input on a document + /// or later asking for each kind once, whose `permissions` list `doc.read` so that installing + /// it asks for approval. One that installs and echoes its input on a document /// with type, images and symbols leaves the document as it was, and it still renders. #[test] fn hostile_manifest_inputs_never_crash( @@ -374,7 +375,7 @@ proptest! { long in prop::option::weighted(0.1, 4usize..300), inputs in prop::option::weighted(0.8, prop_oneof![Just(Value::Null), arb_junk()]), api in prop_oneof![2 => Just(json!(2)), 1 => Just(json!(1)), 1 => Just(json!(3)), 1 => arb_junk()], - permissions in prop_oneof![Just(Value::Null), Just(json!(["doc.read", "doc.write"])), Just(json!(["doc.write"]))], + permissions in prop_oneof![Just(Value::Null), Just(json!([])), Just(json!(["doc.read", "doc.write"])), Just(json!(["doc.write"]))], ) { let mut m = json!({"id": "fuzz.inputs", "name": "Inputs", "kind": kind, "api": api}); if kind == "exporter" { @@ -396,6 +397,8 @@ proptest! { prop_assert_eq!(kinds.len(), man.inputs.len(), "{}", text); prop_assert!(kinds.iter().enumerate().all(|(i, k)| !kinds[..i].contains(k)), "{}", text); prop_assert!(kinds.is_empty() || (man.kind == vectorcraft_plugins::Kind::Filter && man.api >= 2), "{}", text); + let reads = man.permissions.as_ref().is_some_and(|p| p.iter().any(|n| n == "doc.read")); + prop_assert!(kinds.is_empty() || (man.asks_permissions() && reads), "{}", text); } let mut s = rich_session(); let module = wat::parse_str(tpl::module(&text, tpl::ECHO, "", 1)).unwrap(); diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 0016c533c..1c9bca8bc 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -284,8 +284,8 @@ pub struct Manifest { #[serde(default, skip_serializing_if = "Option::is_none")] pub permissions: Option>, /// The kinds of object a filter asks to be given besides paths and compound paths, by - /// [`InputKind::name`]: only a filter at `api` 2 or later lists them. Left out or empty, it is - /// given paths and compound paths alone. + /// [`InputKind::name`]: only a filter at `api` 2 or later whose `permissions` list `doc.read` + /// lists them. Left out or empty, it is given paths and compound paths alone. #[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "input_names")] pub inputs: Vec, /// Parameters in declaration (dialog) order; a JSON object `{"name": spec, …}` in the manifest. @@ -377,7 +377,8 @@ impl Manifest { } /// `inputs` names each [`InputKind`] at most once, and only a filter at `api` 2 or later lists - /// any, with `doc.read` among its permissions when it lists them. + /// any, with a `permissions` list that holds `doc.read`: installing it then asks the user to + /// grant them ([`Manifest::asks_permissions`]). fn validate_inputs(&self) -> Result<()> { if self.inputs.is_empty() { return Ok(()); @@ -397,7 +398,7 @@ impl Manifest { if self.api < INPUTS_API { return Err(Error::Manifest("`inputs` needs `api` 2 or later".into())); } - if self.permissions.as_ref().is_some_and(|p| !p.iter().any(|n| n == Permission::DocRead.name())) { + if !self.permissions.as_ref().is_some_and(|p| p.iter().any(|n| n == Permission::DocRead.name())) { return Err(Error::Manifest("`inputs` needs the permission `doc.read`".into())); } Ok(()) @@ -758,17 +759,20 @@ mod tests { } /// A filter at `api` 2 may ask for type, images and symbol instances (`inputs`), each kind once, - /// with `doc.read` when it lists permissions. Left out or empty, it asks for none and isn't - /// written back. Anything else is refused naming `inputs`. + /// with a `permissions` list holding `doc.read`, so installing it asks for approval. Left out + /// or empty, it asks for none and isn't written back. Anything else is refused naming `inputs`. #[test] fn a_filter_at_api_2_lists_the_inputs_it_reads() { - let m = - Manifest::parse(br#"{"id":"org.example.inspect","name":"Inspect","kind":"filter","api":2,"inputs":["type","image","symbolInstance"]}"#) - .unwrap(); + let m = Manifest::parse( + br#"{"id":"org.example.inspect","name":"Inspect","kind":"filter","api":2,"permissions":["doc.read","doc.write"],"inputs":["type","image","symbolInstance"]}"#, + ) + .unwrap(); assert_eq!(m.input_kinds(), InputKind::ALL); + assert!(m.asks_permissions()); assert_eq!(serde_json::to_value(&m).unwrap()["inputs"], json!(["type", "image", "symbolInstance"])); let some = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["doc.read"],"inputs":["image"]}"#).unwrap(); assert_eq!(some.input_kinds(), [InputKind::Image]); + assert!(some.asks_permissions()); for none in [ r#"{"id":"a","name":"x","kind":"filter"}"#, r#"{"id":"a","name":"x","kind":"filter","inputs":[]}"#, @@ -789,6 +793,8 @@ mod tests { (r#"{"id":"a","name":"x","kind":"filter","inputs":["type"]}"#, "`inputs` needs `api` 2"), (r#"{"id":"a","name":"x","kind":"filter","api":1,"inputs":["type"]}"#, "`inputs` needs `api` 2"), (r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["doc.write"],"inputs":["type"]}"#, "needs the permission `doc.read`"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":["type"]}"#, "`inputs` needs the permission `doc.read`"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":[],"inputs":["image"]}"#, "`inputs` needs the permission `doc.read`"), (r#"{"id":"a","name":"x","kind":"effect","api":2,"inputs":["type"]}"#, "only a filter declares `inputs`"), (r#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"],"api":2,"inputs":["image"]}"#, "only a filter declares `inputs`"), ] { diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index 9fa37ce54..e8db87319 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -11,7 +11,8 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ - Appearance, AppearanceItem, Document, Effect, FillLayer, ImageBlob, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer, TextKind, TextObject, + Appearance, AppearanceItem, Document, Effect, FillLayer, ImageBlob, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer, SymbolBoxes, + TextKind, TextObject, }; use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect}; @@ -42,6 +43,12 @@ pub const MAX_INPUT_RUNS: usize = 4_096; /// Largest pixels a filter is given of one embedded image, in characters of base64: a larger /// image comes with `pixelsOmitted`. pub const MAX_INPUT_PIXELS: usize = 1 << 20; +/// Most pixels a filter is given over all the images of one input, in characters of base64: an +/// image whose pixels don't fit what the images before it left comes with `pixelsOmitted`. +pub const MAX_INPUT_PIXELS_TOTAL: usize = 16 << 20; +/// Most text a filter is given over all the type of one input, in bytes of the JSON of their runs +/// and paragraphs: type that doesn't fit what the type before it left comes with `truncated`. +pub const MAX_INPUT_TEXT_TOTAL: usize = 16 << 20; /// One object returned by a plug-in. `None` fields keep the object's value (new objects take the /// defaults: a path, non-zero rule, no fills or strokes, full opacity). @@ -175,23 +182,56 @@ pub fn encode_input(doc: &Document, ids: &[NodeId]) -> Vec { } /// [`encode_input`] for a filter that asks for `kinds` too: objects of those kinds among `ids` are -/// in it as [`encode_read_only`] gives them. Also returns those objects by id, which the filter must -/// return as they were ([`decode_output_with`]). +/// in it as [`encode_read_only`] gives them, within [`MAX_INPUT_PIXELS_TOTAL`] and +/// [`MAX_INPUT_TEXT_TOTAL`] over all of them in order. Also returns those objects by id, which the +/// filter must return as they were ([`decode_output_with`]). pub fn encode_input_with(doc: &Document, ids: &[NodeId], kinds: &[InputKind]) -> (Vec, HashMap) { + encode_input_within(doc, ids, kinds, Allowance::FULL) +} + +/// What an input's images and type may still be given of its totals, as its objects are encoded in +/// order: characters of base64 for pixels, bytes of JSON for runs and paragraphs. +#[derive(Clone, Copy, Debug)] +struct Allowance { + pixels: usize, + text: usize, +} + +impl Allowance { + const FULL: Allowance = Allowance { pixels: MAX_INPUT_PIXELS_TOTAL, text: MAX_INPUT_TEXT_TOTAL }; +} + +/// [`encode_input_with`] within `left` of the totals. Each read-only object is kept once, in the map +/// it returns, and written into the input from there. +fn encode_input_within(doc: &Document, ids: &[NodeId], kinds: &[InputKind], mut left: Allowance) -> (Vec, HashMap) { let mut read_only = HashMap::new(); - let objects: Vec = ids - .iter() - .filter_map(|id| doc.node(*id)) - .filter_map(|n| match input_kind(n).filter(|k| kinds.contains(k)) { - Some(_) => { - let o = encode_read_only(doc, n)?; - read_only.insert(n.id, o.clone()); - Some(o) - } - None => encode(n), - }) - .collect(); - (serde_json::to_vec(&json!({ "objects": objects })).unwrap_or_default(), read_only) + let symbols = doc.symbol_boxes(); + let mut out = b"{\"objects\":[".to_vec(); + let mut first = true; + for n in ids.iter().filter_map(|id| doc.node(*id)) { + let path; + let o: &Value = match input_kind(n).filter(|k| kinds.contains(k)) { + Some(_) => match encode_read_only_within(doc, n, &symbols, &mut left) { + Some(o) => read_only.entry(n.id).insert_entry(o).into_mut(), + None => continue, + }, + None => match encode(n) { + Some(o) => { + path = o; + &path + } + None => continue, + }, + }; + if !std::mem::take(&mut first) { + out.push(b','); + } + if serde_json::to_writer(&mut out, o).is_err() { + return (Vec::new(), HashMap::new()); + } + } + out.extend_from_slice(b"]}"); + (out, read_only) } /// The kind of read-only object `n` is: type, an image or a symbol instance (`None` for others). @@ -222,11 +262,20 @@ fn affine_json(xf: Affine) -> Value { /// - a symbol instance (`"symbolInstance"`): `symbolId` (the symbol's name), `missing: true` when /// the document has no such symbol, and `overrides: {}`. pub fn encode_read_only(doc: &Document, n: &Node) -> Option { - let mut o = json!({"id": n.id.0, "type": input_kind(n)?.name(), "bounds": rect_json(doc.bounds_of(&[n.id], false))}); + let mut left = Allowance::FULL; + encode_read_only_within(doc, n, &doc.symbol_boxes(), &mut left) +} + +/// [`encode_read_only`] measuring symbol instances from `symbols`, a cache shared across the +/// objects of one input, and taking the pixels and text it gives from `left`: pixels or text that +/// don't fit what is left are omitted (`pixelsOmitted`, `truncated`), and an image's size is +/// checked against it before anything is decoded. +fn encode_read_only_within(doc: &Document, n: &Node, symbols: &SymbolBoxes, left: &mut Allowance) -> Option { + let mut o = json!({"id": n.id.0, "type": input_kind(n)?.name(), "bounds": rect_json(doc.bounds_of_with(&[n.id], false, symbols))}); match &n.kind { NodeKind::Text(t) => { o["transform"] = affine_json(t.xf); - let (text, truncated) = text_json(t); + let (text, truncated) = text_json(t, &mut left.text); o["text"] = text; o["truncated"] = json!(truncated); } @@ -235,13 +284,15 @@ pub fn encode_read_only(doc: &Document, n: &Node) -> Option { let linked = im.link.is_some(); let blob = doc.image_blob(im); // A linked image's pixels are its file's: never given (nor its path). - let pixels = blob.filter(|_| !linked).and_then(|b| rgba_pixels(b, im.width, im.height)); + let pixels = blob.filter(|_| !linked).and_then(|b| rgba_pixels(b, im.width, im.height, MAX_INPUT_PIXELS.min(left.pixels))); o["pixelSize"] = json!([im.width, im.height]); o["colorMode"] = json!(blob.map_or("unknown", color_mode)); o["linked"] = json!(linked); o["pixelsOmitted"] = json!(pixels.is_none()); if let Some(p) = pixels { - o["pixelsBase64"] = json!(craft_vectorcraft_format::base64::encode(&p)); + let b64 = craft_vectorcraft_format::base64::encode(&p); + left.pixels = left.pixels.saturating_sub(b64.len()); + o["pixelsBase64"] = json!(b64); } } NodeKind::SymbolInstance { symbol, xf } => { @@ -261,8 +312,9 @@ pub fn encode_read_only(doc: &Document, n: &Node) -> Option { } /// The text of type `t` as a filter reads it, and whether it was too long to give -/// ([`MAX_INPUT_TEXT`], [`MAX_INPUT_RUNS`]). -fn text_json(t: &TextObject) -> (Value, bool) { +/// ([`MAX_INPUT_TEXT`], [`MAX_INPUT_RUNS`], or more bytes of JSON than `left`, which what it gives +/// is taken from). +fn text_json(t: &TextObject, left: &mut usize) -> (Value, bool) { let kind = match t.kind { TextKind::Point => "point", TextKind::Area { .. } => "area", @@ -270,16 +322,21 @@ fn text_json(t: &TextObject) -> (Value, bool) { }; let bytes = t.runs.iter().fold(0usize, |n, r| n.saturating_add(r.text.len())); let paragraphs = t.paragraph_count(); - let truncated = bytes > MAX_INPUT_TEXT || t.runs.len() > MAX_INPUT_RUNS || paragraphs > MAX_INPUT_RUNS; - let (runs, paras): (Vec, Vec) = if truncated { - (Vec::new(), Vec::new()) - } else { + // The text alone is never more bytes than its JSON, so type that can't fit isn't built. + let mut truncated = bytes > MAX_INPUT_TEXT.min(*left) || t.runs.len() > MAX_INPUT_RUNS || paragraphs > MAX_INPUT_RUNS; + let (mut runs, mut paras): (Vec, Vec) = (Vec::new(), Vec::new()); + if !truncated { let value = |v: std::result::Result| v.unwrap_or(Value::Null); - ( - t.runs.iter().map(|r| value(serde_json::to_value(r))).collect(), - (0..paragraphs).map(|i| value(serde_json::to_value(t.para_at(i)))).collect(), - ) - }; + runs = t.runs.iter().map(|r| value(serde_json::to_value(r))).collect(); + paras = (0..paragraphs).map(|i| value(serde_json::to_value(t.para_at(i)))).collect(); + let size = runs.iter().chain(¶s).fold(0usize, |n, v| n.saturating_add(serde_json::to_vec(v).map_or(usize::MAX, |b| b.len()))); + match left.checked_sub(size) { + Some(rest) => *left = rest, + None => { + (runs, paras, truncated) = (Vec::new(), Vec::new(), true); + } + } + } (json!({"kind": kind, "vertical": t.vertical, "runs": runs, "paragraphs": paras}), truncated) } @@ -300,11 +357,11 @@ fn color_mode(b: &ImageBlob) -> &'static str { if readable.is_some() { "rgb" } else { "unknown" } } -/// The pixels of image `b` as straight RGBA, when it is `w` × `h` pixels and they fit -/// [`MAX_INPUT_PIXELS`] in base64; the size is checked from the header before anything is decoded. -fn rgba_pixels(b: &ImageBlob, w: u32, h: u32) -> Option> { +/// The pixels of image `b` as straight RGBA, when it is `w` × `h` pixels and they fit `max` +/// characters of base64; the size is checked, and read from the header, before anything is decoded. +fn rgba_pixels(b: &ImageBlob, w: u32, h: u32, max: usize) -> Option> { let bytes = u64::from(w) * u64::from(h) * 4; - if bytes == 0 || bytes.div_ceil(3) * 4 > MAX_INPUT_PIXELS as u64 { + if bytes == 0 || bytes.div_ceil(3) * 4 > max as u64 { return None; } let reader = || image::ImageReader::new(std::io::Cursor::new(b.bytes.as_slice())).with_guessed_format().ok(); @@ -629,7 +686,9 @@ fn decode_objects(v: &Value, read_only: &HashMap) -> Result Result<()> { if o.as_object().is_some_and(|m| m.len() == 1) { return Ok(()); @@ -647,7 +706,10 @@ fn check_unchanged(o: &Value, given: &Value, at: &str) -> Result<()> { }; match changed.or_else(|| first_difference(o, given, "")) { None => Ok(()), - Some(field) => Err(out_err(at, format!("read-only field '{field}' changed"))), + Some(field) => { + let field = crate::permissions::display_text(&field, crate::permissions::MAX_SHOWN_FIELD); + Err(out_err(at, format!("read-only field '{field}' changed"))) + } } } diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs index 48b0a38b3..dddf62fbf 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -471,6 +471,57 @@ fn declared_symbol_instance_keeps_identity_and_transform() { assert_eq!((&g["symbolId"], &g["missing"]), (&json!("Gone"), &json!(true))); } +/// Pixels and type count against totals over the whole input, in order: an image whose pixels +/// don't fit what the images before it left comes with `pixelsOmitted` (its size checked before it +/// is decoded), and type whose runs and paragraphs don't fit comes with `truncated`. The read-only +/// objects returned are the ones in the input. +#[test] +fn input_totals_cover_images_and_type_in_order() { + let (mut d, _, layer, [text, embedded, ..]) = mixed(); + let copy = |d: &mut Document, id: NodeId| { + let mut n = d.node(id).unwrap().clone(); + n.id = d.alloc_id(); + d.insert(Some(layer), usize::MAX, n).unwrap() + }; + let (text2, image2, image3) = (copy(&mut d, text), copy(&mut d, embedded), copy(&mut d, embedded)); + let one = encode_read_only(&d, d.node(text).unwrap()).unwrap(); + let size: usize = ["runs", "paragraphs"].iter().flat_map(|k| one["text"][k].as_array().unwrap()).map(|v| v.to_string().len()).sum(); + // A 2 × 1 image is 12 characters of base64: two fit 30, the third doesn't. + let ids = [text, text2, embedded, image2, image3]; + let (bytes, read_only) = encode_input_within(&d, &ids, &InputKind::ALL, Allowance { pixels: 30, text: size }); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + let objects = v["objects"].as_array().unwrap(); + assert_eq!(objects.len(), 5); + let flags: Vec<&Value> = objects.iter().map(|o| if o["type"] == "type" { &o["truncated"] } else { &o["pixelsOmitted"] }).collect(); + assert_eq!(flags, [&json!(false), &json!(true), &json!(false), &json!(false), &json!(true)]); + assert!(objects[1]["text"]["runs"].as_array().unwrap().is_empty() && objects[4].get("pixelsBase64").is_none()); + assert_eq!(objects[3]["pixelsBase64"], encode_read_only(&d, d.node(embedded).unwrap()).unwrap()["pixelsBase64"]); + for o in objects { + assert_eq!(Some(o), read_only.get(&NodeId(o["id"].as_u64().unwrap()))); + } + // Within the full totals every one of them is given. + let (bytes, _) = encode_input_with(&d, &ids, &InputKind::ALL); + let all = String::from_utf8(bytes).unwrap(); + assert!(!all.contains("\"pixelsOmitted\":true") && !all.contains("\"truncated\":true"), "{all}"); +} + +/// A field a plug-in adds to a read-only object is named in the refusal with its control and +/// direction characters escaped, and cut short. +#[test] +fn a_changed_field_is_named_escaped_and_short() { + let (d, _, layer, [text, ..]) = mixed(); + let targets = filter_targets_with(&d, &[layer], &[InputKind::Type]); + let (bytes, read_only) = encode_input_with(&d, &targets, &[InputKind::Type]); + let mut v: Value = serde_json::from_slice(&bytes).unwrap(); + let at = v["objects"].as_array().unwrap().iter().position(|o| o["id"] == json!(text.0)).unwrap(); + let key = format!("\u{202E}{}", "k".repeat(500)); + v["objects"][at][key.as_str()] = json!(1); + let e = decode_output_with(v.to_string().as_bytes(), &read_only).unwrap_err().to_string(); + let shown = e.split('\'').nth(1).unwrap_or_default(); + assert!(!e.contains('\u{202E}') && shown.starts_with("\\u{202E}kkk") && shown.ends_with('…'), "{e}"); + assert_eq!(shown.chars().count(), crate::permissions::MAX_SHOWN_FIELD, "{e}"); +} + /// A filter given read-only objects returns each one as `{"id": …}` or exactly as given (numbers by /// value), and they stay as they are, new objects listed after one going just above it. Any change, /// an object left out or listed twice, and a new object of such a kind are refused before anything diff --git a/docs/mcp.md b/docs/mcp.md index d2606e316..af66deab4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6087,9 +6087,11 @@ them again at startup (`plugin.remove` deletes them there): `plugin.install {path | dataBase64}` installs one, `plugin.list` / `plugin.info {id}` describe them (parameters, schema, defaults). An object filter runs on the selected paths and compound paths with `plugin.run {id, params}` (one undo step; it may change, delete or add objects, and the result is selected). A filter whose manifest lists `inputs` -(`type`, `image`, `symbolInstance`; `inputs` in its `plugin.list` entry) is also given the selected objects of those -kinds to read: its output returns each of them as `{"id": …}` or exactly as given, otherwise `plugin.run` returns an -error naming the object and the field, and the document and undo history are unchanged. A live-effect plug-in is an effect with +(`type`, `image`, `symbolInstance`; `inputs` in its `plugin.list` entry) also lists `doc.read` in its `permissions`, so +`plugin.install` needs `grant`. It is also given the selected objects of those kinds to read, with at most 16 MiB of +pixels and 16 MiB of type runs over one run (later ones come with `pixelsOmitted` or `truncated`): its output returns +each of them as `{"id": …}` or exactly as given, otherwise `plugin.run` returns an error naming the object and the +field, and the document and undo history are unchanged. A live-effect plug-in is an effect with 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 diff --git a/docs/plugins.md b/docs/plugins.md index fb1c06a6e..05132fc02 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -148,7 +148,7 @@ capability model proposed for PhotoCraft plug-ins imports](#host-imports-api-level-2) and a filter's `inputs` ([reading type, images and symbols](#reading-type-images-and-symbols)). A higher one is refused. `plugin.list` and `plugin.info` give it as `api`. `inputs` is not a permission: the objects it asks for are read under `doc.read`, which a filter that lists - `permissions` and `inputs` must list. + `inputs` must list in `permissions`, so installing it asks the user to grant them. - Parameter declarations are unchanged by permissions (`params`, below). Every run gets a **fresh instance**, so no state survives between calls. Traps, exhausted budgets, malformed @@ -243,7 +243,7 @@ still returns `1`. - `inputs` (filters at `api` 2 only): the kinds of object besides paths and compound paths the filter reads, any of `"type"`, `"image"` and `"symbolInstance"`, each at most once (`"inputs": ["type", "image"]`). Left out or `[]`, the filter gets paths and compound paths alone, as before. An unknown, repeated or wrongly typed entry, `inputs` on - another kind, below `api` 2, or with `permissions` that leave out `doc.read`, is refused when the plug-in is + another kind, below `api` 2, or without a `permissions` list that holds `doc.read`, is refused when the plug-in is installed, and so is `api` 2 on hosts from before it. - `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`): @@ -359,12 +359,16 @@ In this example the character and paragraph styles are cut short; a filter gets attributes as the native file stores them (`inline` when a run is an inline graphic), and `paragraphs` holds one paragraph style per paragraph (the paragraphs are split at `\n`). No font data or glyph outlines are given. Type whose runs hold more than 256 KiB of UTF-8 text, or more than 4,096 runs or paragraphs, comes with `truncated: - true` and empty `runs` and `paragraphs`. + true` and empty `runs` and `paragraphs`. The runs and paragraphs of all the type in one input take at most 16 MiB + of JSON: type whose runs and paragraphs don't fit what the type before it in paint order left comes with + `truncated: true` too. - An image (`"image"`): `pixelSize` is its width and height in pixels; `colorMode` is `"rgb"`, `"cmyk"`, `"gray"` or `"unknown"`, read from the image itself. An embedded image whose pixels take at most 1 MiB in base64 (443 × 443 pixels fit) has them in `pixelsBase64`: straight RGBA, 8 bits a channel, row by row from the top, and - `pixelsOmitted: false`. A larger one, and a linked image (`linked: true`), has `pixelsOmitted: true` and no pixels. - The host never reads a linked image's file for a filter and never gives its path. + `pixelsOmitted: false`. The pixels of all the images in one input take at most 16 MiB in base64. A larger image, one + whose pixels don't fit what the images before it in paint order left, and a linked image (`linked: true`) have + `pixelsOmitted: true` and no pixels; the host checks an image's size before it decodes the image. The host never + reads a linked image's file for a filter and never gives its path. - A symbol instance (`"symbolInstance"`): `symbolId` is the name of the symbol it shows, with `missing: true` when the document has no symbol of that name. `overrides` is `{}`. @@ -372,8 +376,9 @@ These objects are read only. The output must list each of them exactly once, as was given (numbers compare by value, so `1` and `1.0` are the same). A changed or added field, one left out, an object listed twice or left out, and a new object with one of these types are refused, and the document and undo history stay as they were: `objects[1] (id 17): read-only field 'text.runs[0].text' changed`, `objects (id 17): a read-only -object was left out`. New objects listed after one of them go just above it. Their text and pixels count towards the -input's half of the memory cap, and an echo of them towards the 64 MiB output cap. +object was left out`. The field is named with control and bidirectional characters escaped (`\u{202E}`), cut to 64 +characters. New objects listed after one of them go just above it. Their text and pixels count toward the input's +half of the memory cap, and an echo of them toward the 64 MiB output cap. ### Parameters diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index dab0ea86e..812e777ed 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1488,7 +1488,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **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 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. -- **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs), images with their size, color mode and, when embedded and at most 1 MiB in base64, their RGBA pixels (never a linked file's), and symbol instances with their symbol and transform. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. +- **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) with `doc.read` in its `permissions`, so installing it asks for approval, and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs, or 16 MiB of runs over the input), images with their size, color mode and, when embedded and at most 1 MiB in base64 (16 MiB over the input), their RGBA pixels (never a linked file's), and symbol instances with their symbol and transform. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.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 e5674ba1c5c1171507bf484a30269c436a5d596a Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sun, 8 Nov 2026 04:40:55 -0500 Subject: [PATCH 35/71] EPS/.ai editing data: test a stack of a solid fill and a stroke without a brush A path whose style is a solid fill under a 50% Screen stroke comes in as itself with both, each with its own transparency. --- crates/eps/src/import/ai/tests.rs | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 43c24c0e1..7d907f727 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -5372,6 +5372,39 @@ fn a_path_with_several_gradient_fills_comes_in_as_itself_where_its_look_places_t assert!(flat.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", flat.warnings); } +/// A stack of solid colours comes in live too: a path whose style is a solid fill under a stroke +/// without a brush at 50 % Screen comes in as itself with both, each with its own transparency. +#[test] +fn a_path_with_a_solid_fill_under_a_screened_stroke_comes_in_as_itself() { + let stroke = "/BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n\ + /StrokeStyle : 0 R\n0 1 1 0 1 0 0 XA\n0 J 0 j 2 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n"; + let over = "/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\ + /FillStyle : 0 O\n1 (B) 0 0 0 1 1 0 0 1 0 0 Bg\n0 1 Xd\n/Def ;\n (FillStyle) ,\n"; + let style = two_gradient_style("A", "B"); + assert!(style.contains(over)); + let style = style.replace(over, stroke).replace("1 (A) 0 0 0 1 1 0 0 1 0 0 Bg\n", "0 0 0 1 0 0 0 Xa\n"); + let rect = rect_path(0.0, 0.0, 100.0, 50.0); + let hidden_rect = rect.replace('\n', "\n%_"); + let square = format!("0 Ae\nu\n0 0 0 1 0 0 0 Xa\n{rect}f\n0 1 1 0 1 0 0 XA\n2 w\n{rect}S\nU\n%_{hidden_rect}f\n1 (Glow) XW\n"); + let r = read_ok(&format!("%AI9_BeginArtStyles\n{style}%AI9_EndArtStyles\n{}", layer(&square))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look: {a:#?}"); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); + let items: Vec<_> = a[0] + .appearance + .items + .iter() + .map(|i| match i { + AppearanceItem::Fill(f) => ("fill", matches!(f.paint, Paint::Solid { .. }), f.opacity, f.blend), + AppearanceItem::Stroke(s) => { + ("stroke", matches!(s.paint, Paint::Solid { .. }) && s.brush.is_none() && s.width == 2.0, s.opacity, s.blend) + } + }) + .collect(); + assert_eq!(items, [("fill", true, 1.0, BlendMode::Normal), ("stroke", true, 0.5, BlendMode::Screen)]); +} + /// An art style named `name` (its bytes as written) that fills with colour `xa` (`c m y k r g b Xa`). fn fill_style(name: &str, xa: &str) -> String { fill_style_with(name, xa, "") From 96bdb813a6628290696bf80dad40430f27c06572 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 04:42:17 -0800 Subject: [PATCH 36/71] Plug-ins: read-only inputs count toward the input's memory share A filter's input is now encoded within the half of the plug-in's memory cap that the input and the parameters share. Every object counts toward it in paint order, with its id, bounds, transform and name. Type runs and image pixels that don't fit what is left come with truncated or pixelsOmitted, and a type, image or symbol instance whose other fields alone don't fit fails the run before the plug-in starts with "memory budget (object N needs S bytes of input, L left)". --- crates/engine/src/cmd/plugin.rs | 6 +- crates/plugins/src/objects.rs | 153 ++++++++++++++++++---------- crates/plugins/src/objects/tests.rs | 53 ++++++++-- docs/mcp.md | 5 +- docs/plugins.md | 10 +- docs/target-app-parity.md | 2 +- 6 files changed, 164 insertions(+), 65 deletions(-) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index f352bdac1..e5ecc9992 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -162,8 +162,10 @@ impl FilterRun { // What the plug-in keeps with the document, as it last wrote it. let data = st.doc.plugin_data(&m.id).filter(|_| may.contains(&Permission::State)).cloned().unwrap_or(Value::Null); params["_context"] = json!({"mode": "filter", "bounds": bounds.map(rect), "artboard": board.map(|a| rect(a.rect)), "documentData": data}); - let (input, read_only) = objects::encode_input_with(&st.doc, &inputs, &kinds); let params = serde_json::to_vec(¶ms).map_err(|e| bad(C, e.to_string()))?; + // The input and the parameters may use half the plug-in's memory together. + let capacity = (plugin.limits().max_memory_bytes / 2).min(i32::MAX as usize).saturating_sub(params.len()); + let (input, read_only) = objects::encode_input_with(&st.doc, &inputs, &kinds, capacity).map_err(err)?; let document = (st.uid, st.revision); Ok(FilterRun { plugin, input, params, inputs, read_only, parent, document }) } @@ -531,7 +533,7 @@ pub fn specs() -> Vec { "Run Plug-in", [], None, - "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. A filter whose manifest lists inputs (type, image, symbolInstance; it lists doc.read in permissions, so plugin.install needs grant) is also given the selected objects of those kinds to read, with at most 16 MiB of pixels and 16 MiB of type runs per run (later ones come with pixelsOmitted or truncated): its output returns each as {id} or exactly as given, or the run is refused and nothing changes. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}", + "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. A filter whose manifest lists inputs (type, image, symbolInstance; it lists doc.read in permissions, so plugin.install needs grant) is also given the selected objects of those kinds to read, with at most 16 MiB of pixels and 16 MiB of type runs per run (later ones come with pixelsOmitted or truncated); every object counts toward the input's half of the plug-in's memory cap, ids, bounds, transforms and names included, and an object whose fields besides its pixels and runs don't fit fails the run with a memory-budget error naming it and its size: its output returns each as {id} or exactly as given, or the run is refused and nothing changes. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}", has_doc, run ), diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index e8db87319..fa309ae62 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -178,60 +178,80 @@ pub fn encode(n: &Node) -> Option { /// The input document of a filter run: `{"objects": [...]}` for `ids` (paths and compound paths; /// others are left out). pub fn encode_input(doc: &Document, ids: &[NodeId]) -> Vec { - encode_input_with(doc, ids, &[]).0 + encode_input_with(doc, ids, &[], usize::MAX).map(|(bytes, _)| bytes).unwrap_or_default() } /// [`encode_input`] for a filter that asks for `kinds` too: objects of those kinds among `ids` are /// in it as [`encode_read_only`] gives them, within [`MAX_INPUT_PIXELS_TOTAL`] and -/// [`MAX_INPUT_TEXT_TOTAL`] over all of them in order. Also returns those objects by id, which the -/// filter must return as they were ([`decode_output_with`]). -pub fn encode_input_with(doc: &Document, ids: &[NodeId], kinds: &[InputKind]) -> (Vec, HashMap) { - encode_input_within(doc, ids, kinds, Allowance::FULL) +/// [`MAX_INPUT_TEXT_TOTAL`] over all of them in order, and within `capacity`, the most bytes the +/// input may be. Every object counts toward `capacity` in order, ids, bounds, transforms and names +/// included: pixels or text that don't fit what is left are omitted (`pixelsOmitted`, +/// `truncated`), and a read-only object whose other fields alone don't fit is an +/// [`Error::Limit`] naming it and its size. Also returns those objects by id, which the filter +/// must return as they were ([`decode_output_with`]). +pub fn encode_input_with(doc: &Document, ids: &[NodeId], kinds: &[InputKind], capacity: usize) -> Result<(Vec, HashMap)> { + encode_input_within(doc, ids, kinds, Allowance { input: capacity.saturating_sub(EMPTY_INPUT.len()), ..Allowance::FULL }) } -/// What an input's images and type may still be given of its totals, as its objects are encoded in -/// order: characters of base64 for pixels, bytes of JSON for runs and paragraphs. +/// The input with no objects; each object adds its JSON and a comma. +const EMPTY_INPUT: &[u8] = b"{\"objects\":[]}"; + +/// What an input's objects may still be given of its totals, as they are encoded in order: +/// characters of base64 for pixels, bytes of JSON for runs and paragraphs, and bytes of the whole +/// input. #[derive(Clone, Copy, Debug)] struct Allowance { pixels: usize, text: usize, + input: usize, } impl Allowance { - const FULL: Allowance = Allowance { pixels: MAX_INPUT_PIXELS_TOTAL, text: MAX_INPUT_TEXT_TOTAL }; + const FULL: Allowance = Allowance { pixels: MAX_INPUT_PIXELS_TOTAL, text: MAX_INPUT_TEXT_TOTAL, input: usize::MAX }; } /// [`encode_input_with`] within `left` of the totals. Each read-only object is kept once, in the map /// it returns, and written into the input from there. -fn encode_input_within(doc: &Document, ids: &[NodeId], kinds: &[InputKind], mut left: Allowance) -> (Vec, HashMap) { +fn encode_input_within(doc: &Document, ids: &[NodeId], kinds: &[InputKind], mut left: Allowance) -> Result<(Vec, HashMap)> { let mut read_only = HashMap::new(); let symbols = doc.symbol_boxes(); let mut out = b"{\"objects\":[".to_vec(); let mut first = true; for n in ids.iter().filter_map(|id| doc.node(*id)) { let path; - let o: &Value = match input_kind(n).filter(|k| kinds.contains(k)) { - Some(_) => match encode_read_only_within(doc, n, &symbols, &mut left) { - Some(o) => read_only.entry(n.id).insert_entry(o).into_mut(), + let (o, checked): (&Value, bool) = match input_kind(n).filter(|k| kinds.contains(k)) { + Some(_) => match encode_read_only_within(doc, n, &symbols, &mut left)? { + Some(o) => (read_only.entry(n.id).insert_entry(o).into_mut(), true), None => continue, }, None => match encode(n) { Some(o) => { path = o; - &path + (&path, false) } None => continue, }, }; + let start = out.len(); if !std::mem::take(&mut first) { out.push(b','); } if serde_json::to_writer(&mut out, o).is_err() { - return (Vec::new(), HashMap::new()); + return Ok((Vec::new(), HashMap::new())); } + let size = out.len() - start; + if checked && size > left.input { + return Err(too_large(n, size, left.input)); + } + left.input = left.input.saturating_sub(size); } out.extend_from_slice(b"]}"); - (out, read_only) + Ok((out, read_only)) +} + +/// The error for read-only object `n`, which needs `size` bytes of input where `left` are left. +fn too_large(n: &Node, size: usize, left: usize) -> Error { + Error::Limit(format!("memory budget (object {} needs {size} bytes of input, {left} left)", n.id.0)) } /// The kind of read-only object `n` is: type, an image or a symbol instance (`None` for others). @@ -263,37 +283,34 @@ fn affine_json(xf: Affine) -> Value { /// the document has no such symbol, and `overrides: {}`. pub fn encode_read_only(doc: &Document, n: &Node) -> Option { let mut left = Allowance::FULL; - encode_read_only_within(doc, n, &doc.symbol_boxes(), &mut left) + encode_read_only_within(doc, n, &doc.symbol_boxes(), &mut left).ok().flatten() } +/// The bytes the `pixelsBase64` key adds to an image's JSON besides the base64 itself. +const PIXELS_KEY: usize = ",\"pixelsBase64\":\"\"".len(); + /// [`encode_read_only`] measuring symbol instances from `symbols`, a cache shared across the /// objects of one input, and taking the pixels and text it gives from `left`: pixels or text that -/// don't fit what is left are omitted (`pixelsOmitted`, `truncated`), and an image's size is -/// checked against it before anything is decoded. -fn encode_read_only_within(doc: &Document, n: &Node, symbols: &SymbolBoxes, left: &mut Allowance) -> Option { - let mut o = json!({"id": n.id.0, "type": input_kind(n)?.name(), "bounds": rect_json(doc.bounds_of_with(&[n.id], false, symbols))}); +/// don't fit what is left of their totals or of the input are omitted (`pixelsOmitted`, +/// `truncated`), and an image's size is checked against it before anything is decoded. The object's +/// other fields are measured first, with a comma before it: when they alone don't fit what is left +/// of the input, it is an [`Error::Limit`] naming the object and that size. +fn encode_read_only_within(doc: &Document, n: &Node, symbols: &SymbolBoxes, left: &mut Allowance) -> Result> { + let Some(kind) = input_kind(n) else { return Ok(None) }; + let mut o = json!({"id": n.id.0, "type": kind.name(), "bounds": rect_json(doc.bounds_of_with(&[n.id], false, symbols))}); + // Each flag is measured as `false`, its longer value. match &n.kind { NodeKind::Text(t) => { o["transform"] = affine_json(t.xf); - let (text, truncated) = text_json(t, &mut left.text); - o["text"] = text; - o["truncated"] = json!(truncated); + o["text"] = text_json(t, None).0; + o["truncated"] = json!(false); } NodeKind::Image(im) => { o["transform"] = affine_json(im.xf); - let linked = im.link.is_some(); - let blob = doc.image_blob(im); - // A linked image's pixels are its file's: never given (nor its path). - let pixels = blob.filter(|_| !linked).and_then(|b| rgba_pixels(b, im.width, im.height, MAX_INPUT_PIXELS.min(left.pixels))); o["pixelSize"] = json!([im.width, im.height]); - o["colorMode"] = json!(blob.map_or("unknown", color_mode)); - o["linked"] = json!(linked); - o["pixelsOmitted"] = json!(pixels.is_none()); - if let Some(p) = pixels { - let b64 = craft_vectorcraft_format::base64::encode(&p); - left.pixels = left.pixels.saturating_sub(b64.len()); - o["pixelsBase64"] = json!(b64); - } + o["colorMode"] = json!(doc.image_blob(im).map_or("unknown", color_mode)); + o["linked"] = json!(im.link.is_some()); + o["pixelsOmitted"] = json!(false); } NodeKind::SymbolInstance { symbol, xf } => { o["transform"] = affine_json(*xf); @@ -303,37 +320,69 @@ fn encode_read_only_within(doc: &Document, n: &Node, symbols: &SymbolBoxes, left } o["overrides"] = json!({}); } - _ => return None, + _ => return Ok(None), } if let Some(name) = &n.name { o["name"] = json!(name); } - Some(o) + let fields = serde_json::to_vec(&o).map_or(usize::MAX, |b| b.len()).saturating_add(1); + if fields > left.input { + return Err(too_large(n, fields, left.input)); + } + let room = left.input - fields; + match &n.kind { + NodeKind::Text(t) => { + let mut budget = left.text.min(room); + let (text, truncated) = text_json(t, Some(&mut budget)); + left.text -= left.text.min(room) - budget; + o["text"] = text; + o["truncated"] = json!(truncated); + } + NodeKind::Image(im) => { + let blob = doc.image_blob(im); + let max = MAX_INPUT_PIXELS.min(left.pixels).min(room.saturating_sub(PIXELS_KEY)); + // A linked image's pixels are its file's: never given (nor its path). + let pixels = blob.filter(|_| im.link.is_none()).and_then(|b| rgba_pixels(b, im.width, im.height, max)); + o["pixelsOmitted"] = json!(pixels.is_none()); + if let Some(p) = pixels { + let b64 = craft_vectorcraft_format::base64::encode(&p); + left.pixels = left.pixels.saturating_sub(b64.len()); + o["pixelsBase64"] = json!(b64); + } + } + _ => {} + } + Ok(Some(o)) } /// The text of type `t` as a filter reads it, and whether it was too long to give /// ([`MAX_INPUT_TEXT`], [`MAX_INPUT_RUNS`], or more bytes of JSON than `left`, which what it gives -/// is taken from). -fn text_json(t: &TextObject, left: &mut usize) -> (Value, bool) { +/// is taken from, with the commas between them). With `left` `None`, its kind and direction with no +/// runs or paragraphs. +fn text_json(t: &TextObject, left: Option<&mut usize>) -> (Value, bool) { let kind = match t.kind { TextKind::Point => "point", TextKind::Area { .. } => "area", TextKind::OnPath { .. } => "path", }; - let bytes = t.runs.iter().fold(0usize, |n, r| n.saturating_add(r.text.len())); - let paragraphs = t.paragraph_count(); - // The text alone is never more bytes than its JSON, so type that can't fit isn't built. - let mut truncated = bytes > MAX_INPUT_TEXT.min(*left) || t.runs.len() > MAX_INPUT_RUNS || paragraphs > MAX_INPUT_RUNS; let (mut runs, mut paras): (Vec, Vec) = (Vec::new(), Vec::new()); - if !truncated { - let value = |v: std::result::Result| v.unwrap_or(Value::Null); - runs = t.runs.iter().map(|r| value(serde_json::to_value(r))).collect(); - paras = (0..paragraphs).map(|i| value(serde_json::to_value(t.para_at(i)))).collect(); - let size = runs.iter().chain(¶s).fold(0usize, |n, v| n.saturating_add(serde_json::to_vec(v).map_or(usize::MAX, |b| b.len()))); - match left.checked_sub(size) { - Some(rest) => *left = rest, - None => { - (runs, paras, truncated) = (Vec::new(), Vec::new(), true); + let mut truncated = false; + if let Some(left) = left { + let bytes = t.runs.iter().fold(0usize, |n, r| n.saturating_add(r.text.len())); + let paragraphs = t.paragraph_count(); + // The text alone is never more bytes than its JSON, so type that can't fit isn't built. + truncated = bytes > MAX_INPUT_TEXT.min(*left) || t.runs.len() > MAX_INPUT_RUNS || paragraphs > MAX_INPUT_RUNS; + if !truncated { + let value = |v: std::result::Result| v.unwrap_or(Value::Null); + runs = t.runs.iter().map(|r| value(serde_json::to_value(r))).collect(); + paras = (0..paragraphs).map(|i| value(serde_json::to_value(t.para_at(i)))).collect(); + let commas = runs.len().saturating_sub(1) + paras.len().saturating_sub(1); + let size = runs.iter().chain(¶s).fold(commas, |n, v| n.saturating_add(serde_json::to_vec(v).map_or(usize::MAX, |b| b.len()))); + match left.checked_sub(size) { + Some(rest) => *left = rest, + None => { + (runs, paras, truncated) = (Vec::new(), Vec::new(), true); + } } } } diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs index dddf62fbf..9277ce708 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -366,7 +366,7 @@ fn legacy_filter_input_bytes_are_unchanged() { assert_eq!(filter_targets(&d, &[layer]), ids); assert_eq!(filter_targets_with(&d, &[layer], &[]), ids); let every: Vec = ids.iter().copied().chain([text, embedded, linked, star, ghost]).collect(); - let (bytes, read_only) = encode_input_with(&d, &every, &[]); + let (bytes, read_only) = encode_input_with(&d, &every, &[], usize::MAX).unwrap(); assert_eq!(bytes, encode_input(&d, &ids), "the other objects are left out"); assert!(read_only.is_empty()); let all = filter_targets_with(&d, &[layer], &InputKind::ALL); @@ -377,7 +377,7 @@ fn legacy_filter_input_bytes_are_unchanged() { d.node_mut(text).unwrap().visible = false; d.node_mut(star).unwrap().locked = true; assert_eq!(filter_targets_with(&d, &[layer], &InputKind::ALL), [&ids[..], &[embedded, linked, ghost]].concat()); - let (bytes, read_only) = encode_input_with(&d, &all, &[InputKind::SymbolInstance]); + let (bytes, read_only) = encode_input_with(&d, &all, &[InputKind::SymbolInstance], usize::MAX).unwrap(); let v: Value = serde_json::from_slice(&bytes).unwrap(); let types: Vec<&str> = v["objects"].as_array().unwrap().iter().map(|o| o["type"].as_str().unwrap()).collect(); assert_eq!(types, ["path", "path", "path", "symbolInstance", "symbolInstance"]); @@ -485,10 +485,11 @@ fn input_totals_cover_images_and_type_in_order() { }; let (text2, image2, image3) = (copy(&mut d, text), copy(&mut d, embedded), copy(&mut d, embedded)); let one = encode_read_only(&d, d.node(text).unwrap()).unwrap(); - let size: usize = ["runs", "paragraphs"].iter().flat_map(|k| one["text"][k].as_array().unwrap()).map(|v| v.to_string().len()).sum(); + // The runs and paragraphs with the commas between them, without their brackets. + let size: usize = ["runs", "paragraphs"].iter().map(|k| one["text"][k].to_string().len() - 2).sum(); // A 2 × 1 image is 12 characters of base64: two fit 30, the third doesn't. let ids = [text, text2, embedded, image2, image3]; - let (bytes, read_only) = encode_input_within(&d, &ids, &InputKind::ALL, Allowance { pixels: 30, text: size }); + let (bytes, read_only) = encode_input_within(&d, &ids, &InputKind::ALL, Allowance { pixels: 30, text: size, input: usize::MAX }).unwrap(); let v: Value = serde_json::from_slice(&bytes).unwrap(); let objects = v["objects"].as_array().unwrap(); assert_eq!(objects.len(), 5); @@ -500,18 +501,56 @@ fn input_totals_cover_images_and_type_in_order() { assert_eq!(Some(o), read_only.get(&NodeId(o["id"].as_u64().unwrap()))); } // Within the full totals every one of them is given. - let (bytes, _) = encode_input_with(&d, &ids, &InputKind::ALL); + let (bytes, _) = encode_input_with(&d, &ids, &InputKind::ALL, usize::MAX).unwrap(); let all = String::from_utf8(bytes).unwrap(); assert!(!all.contains("\"pixelsOmitted\":true") && !all.contains("\"truncated\":true"), "{all}"); } +/// Every object counts toward the input's capacity in order, its id, bounds, transform and name +/// included: a symbol instance before type leaves the type less room, so text that fits the exact +/// size of the whole input comes with `truncated` one byte under it, and an image's pixels are +/// omitted the same way. +#[test] +fn object_fields_count_toward_the_input_capacity() { + let (d, ids, _, [text, embedded, _, star, _]) = mixed(); + for (order, flag) in [([ids[0], star, text], "truncated"), ([ids[0], star, embedded], "pixelsOmitted")] { + let (bytes, _) = encode_input_with(&d, &order, &InputKind::ALL, usize::MAX).unwrap(); + let (exact, _) = encode_input_with(&d, &order, &InputKind::ALL, bytes.len()).unwrap(); + assert_eq!(exact, bytes, "{flag}: the exact size fits"); + let (under, _) = encode_input_with(&d, &order, &InputKind::ALL, bytes.len() - 1).unwrap(); + let v: Value = serde_json::from_slice(&under).unwrap(); + assert_eq!(v["objects"][2][flag], json!(true), "{flag}: one byte under"); + assert!(under.len() < bytes.len() - 1, "{flag}"); + } +} + +/// A read-only object whose fields other than its pixels and text alone don't fit what is left of +/// the input fails the run with a limit naming it and its size, rather than being left out. +#[test] +fn an_object_too_large_to_give_is_a_limit_naming_it() { + let (mut d, ids, _, [_, _, linked, star, _]) = mixed(); + d.node_mut(linked).unwrap().name = Some("n".repeat(4096)); + let (fits, _) = encode_input_with(&d, &[ids[0], star], &InputKind::ALL, usize::MAX).unwrap(); + let order = [ids[0], star, linked]; + let e = encode_input_with(&d, &order, &InputKind::ALL, fits.len() + 4096).unwrap_err(); + assert!(matches!(e, Error::Limit(_)), "{e}"); + // Measured with `pixelsOmitted` false and a comma before it. + let mut o = encode_read_only(&d, d.node(linked).unwrap()).unwrap(); + o["pixelsOmitted"] = json!(false); + let size = o.to_string().len() + 1; + assert_eq!(e.to_string(), format!("plug-in exceeded its memory budget (object {} needs {size} bytes of input, 4096 left)", linked.0)); + // A filter that doesn't list its kind is given the rest. + let (bytes, read_only) = encode_input_with(&d, &order, &[InputKind::SymbolInstance], fits.len() + 4096).unwrap(); + assert_eq!((bytes, read_only.len()), (fits, 1)); +} + /// A field a plug-in adds to a read-only object is named in the refusal with its control and /// direction characters escaped, and cut short. #[test] fn a_changed_field_is_named_escaped_and_short() { let (d, _, layer, [text, ..]) = mixed(); let targets = filter_targets_with(&d, &[layer], &[InputKind::Type]); - let (bytes, read_only) = encode_input_with(&d, &targets, &[InputKind::Type]); + let (bytes, read_only) = encode_input_with(&d, &targets, &[InputKind::Type], usize::MAX).unwrap(); let mut v: Value = serde_json::from_slice(&bytes).unwrap(); let at = v["objects"].as_array().unwrap().iter().position(|o| o["id"] == json!(text.0)).unwrap(); let key = format!("\u{202E}{}", "k".repeat(500)); @@ -530,7 +569,7 @@ fn a_changed_field_is_named_escaped_and_short() { fn read_only_kind_field_and_omission_refuse() { let (mut d, ids, layer, [text, embedded, linked, star, ghost]) = mixed(); let targets = filter_targets_with(&d, &[layer], &InputKind::ALL); - let (bytes, read_only) = encode_input_with(&d, &targets, &InputKind::ALL); + let (bytes, read_only) = encode_input_with(&d, &targets, &InputKind::ALL, usize::MAX).unwrap(); assert_eq!(read_only.len(), 5); let input: Value = serde_json::from_slice(&bytes).unwrap(); let before = d.clone(); diff --git a/docs/mcp.md b/docs/mcp.md index af66deab4..53f6ff89b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6089,7 +6089,10 @@ schema, defaults). An object filter runs on the selected paths and compound path undo step; it may change, delete or add objects, and the result is selected). A filter whose manifest lists `inputs` (`type`, `image`, `symbolInstance`; `inputs` in its `plugin.list` entry) also lists `doc.read` in its `permissions`, so `plugin.install` needs `grant`. It is also given the selected objects of those kinds to read, with at most 16 MiB of -pixels and 16 MiB of type runs over one run (later ones come with `pixelsOmitted` or `truncated`): its output returns +pixels and 16 MiB of type runs over one run (later ones come with `pixelsOmitted` or `truncated`). Every object counts +toward the input's half of the plug-in's memory cap, ids, bounds, transforms and names included: pixels and runs that +don't fit come the same way, and an object whose other fields don't fit makes `plugin.run` return a memory-budget error +naming the object and its size, with nothing changed. Its output returns each of them as `{"id": …}` or exactly as given, otherwise `plugin.run` returns an error naming the object and the field, and the document and undo history are unchanged. A live-effect plug-in is an effect with id `plugin.`: `effect.apply`, `effect.setParams` and `effect.expandAppearance` work as for built-in effects, and diff --git a/docs/plugins.md b/docs/plugins.md index 05132fc02..dd39fe512 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -377,8 +377,14 @@ was given (numbers compare by value, so `1` and `1.0` are the same). A changed o listed twice or left out, and a new object with one of these types are refused, and the document and undo history stay as they were: `objects[1] (id 17): read-only field 'text.runs[0].text' changed`, `objects (id 17): a read-only object was left out`. The field is named with control and bidirectional characters escaped (`\u{202E}`), cut to 64 -characters. New objects listed after one of them go just above it. Their text and pixels count toward the input's -half of the memory cap, and an echo of them toward the 64 MiB output cap. +characters. New objects listed after one of them go just above it. + +Every object in the input counts toward the half of the memory cap that the input and the parameters share, in paint +order, with its id, bounds, transform and name. Type whose runs and paragraphs don't fit what is left comes with +`truncated: true`, and an image whose pixels don't fit comes with `pixelsOmitted: true`. When the other fields of one +of these objects don't fit what is left, the run fails before the plug-in starts, naming the object and the bytes it +needs: `plug-in exceeded its memory budget (object 17 needs 4200 bytes of input, 1000 left)`. An echo of them counts +toward the 64 MiB output cap. ### Parameters diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 812e777ed..fa4a8f0d6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1488,7 +1488,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **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 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. -- **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) with `doc.read` in its `permissions`, so installing it asks for approval, and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs, or 16 MiB of runs over the input), images with their size, color mode and, when embedded and at most 1 MiB in base64 (16 MiB over the input), their RGBA pixels (never a linked file's), and symbol instances with their symbol and transform. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. +- **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) with `doc.read` in its `permissions`, so installing it asks for approval, and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs, or 16 MiB of runs over the input), images with their size, color mode and, when embedded and at most 1 MiB in base64 (16 MiB over the input), their RGBA pixels (never a linked file's), and symbol instances with their symbol and transform. Every object counts toward the input's half of the memory cap; one whose fields besides its pixels and runs don't fit fails the run naming it and its size. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.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 56f8e3208a915d0f37e8ae80119d64e8893639be Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 05:13:18 -0800 Subject: [PATCH 37/71] i18n: French rows keep the no-break space before punctuation --- crates/ui-egui/src/i18n/fr.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 21173799a..c5e915074 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5841,7 +5841,7 @@ axis Y Axis Axe Y @msg permission `{shown}` is listed twice l’autorisation `{shown}` figure deux fois @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it l’autorisation `{shown}` est réservée à une future API de module externe ; cet hôte ne l’accorde pas @msg only a filter declares `inputs` seul un filtre déclare `inputs` -@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` désigne un type d’objet inconnu `{shown}` : utilisez type, image ou symbolInstance +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` désigne un type d’objet inconnu `{shown}` : utilisez type, image ou symbolInstance @msg `inputs` lists `{shown}` twice `inputs` mentionne `{shown}` deux fois @msg `inputs` needs `api` 2 or later `inputs` nécessite `api` 2 ou plus @msg `inputs` needs the permission `doc.read` `inputs` nécessite l’autorisation `doc.read` From 552205941717d295f529fcf8fd10b7af1d933d47 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 16:51:46 -0800 Subject: [PATCH 38/71] Save: the save commands and Export for Screens refuse an unknown option key document.save, file.saveAs, file.saveCopy and file.saveAsTemplate wrote the file when given a key that no save format reads, beside the path or in options, or an unknown key inside a PDF option object such as marks.useDocumentBleed. document.exportForScreens did the same for an unknown top-level key, a key in a format row and a key in the settings of a format. These commands now refuse such a key and name its path (options.noSuchOption, formats[1].marks.useDocumentBleed). The svg object is checked in every save, export and format row, also when the file written is not SVG. A save still leaves out a key that only another save format reads, and warns as before. A format row and a format's settings still take includeArtboardFill and richBlack, which the encoders read. document.exportPresets.save checks its settings, with the rows and settings in it, the same way. In the app, file.save and file.exportForScreens sent as commands are checked as document.save and document.exportForScreens, and app.save is checked before anything is written; MCP's app.save, file.save and file.exportForScreens are checked the same way. The SVG Options dialog no longer passes its internal fields on to the save. --- crates/engine/src/cmd/fileio.rs | 9 +- crates/engine/src/cmd/fileio/commands.rs | 4 +- crates/engine/src/cmd/fileio/encode.rs | 1 + crates/engine/src/cmd/fileio/save.rs | 2 +- crates/engine/src/cmd/fileio/screens.rs | 50 ++++++- crates/engine/src/cmd/params.rs | 165 +++++++++++++++++----- crates/engine/src/tests/params.rs | 156 ++++++++++++++++++++ crates/engine/src/tests/save.rs | 2 +- crates/engine/tests/it/param_keys.rs | 21 +++ crates/mcp/src/headless.rs | 8 +- crates/mcp/src/tests/exportas.rs | 32 +++++ crates/ui-egui/src/control.rs | 7 +- crates/ui-egui/src/dialogs/svg_options.rs | 3 +- crates/ui-egui/src/menus/run.rs | 6 + crates/ui-egui/src/tests/automation.rs | 34 +++++ docs/control-protocol.md | 2 +- docs/mcp.md | 31 +++- docs/target-app-parity.md | 1 + 18 files changed, 475 insertions(+), 59 deletions(-) diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 0c888d13c..b3dab1d12 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -83,16 +83,17 @@ pub use save::{ remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder, }; +pub(crate) use screens::{ + PARAM_KEYS as SCREEN_PARAM_KEYS, ROW_KEYS as SCREEN_ROW_KEYS, UNWRITTEN_FORMATS as SCREEN_UNWRITTEN_FORMATS, + check_settings as check_screen_settings, export as export_screens, safe_file_name, store_settings as store_screen_settings, + with_export_preset as with_screen_export_preset, +}; pub use screens::{ PRESET_FILE_EXTS as SCREEN_EXPORT_PRESET_EXTS, PRESET_FILE_FORMAT as SCREEN_EXPORT_PRESET_FORMAT, PRESET_KEYS as SCREEN_EXPORT_PRESET_KEYS, PRESETS as SCREEN_PRESETS, SHARED_KEYS as SCREEN_SETTINGS_KEYS, ScreenExport, ScreenExportPreset, ScreenSize, builtin_presets as screen_export_builtin_presets, first_file as screen_first_file, preset_list as screen_export_preset_list, preset_rows as screen_preset_rows, }; -pub(crate) use screens::{ - check_settings as check_screen_settings, export as export_screens, safe_file_name, store_settings as store_screen_settings, - with_export_preset as with_screen_export_preset, -}; pub use svg::options_map as svg_options; /// Atomic file writes (a temporary file renamed over the target: a failed write never damages the /// file it replaces), for the apps' own writers too. diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index d698c3aad..7914fc846 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Export for Screens", ["File", "Export"], None, - "{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with for the document, for the artboard, asset, layer or object, from 1, #### for the counter zero-padded to the run of #, , , (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, ,