From d56e241a43e76d501575ebce626b691a426fd3a4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:30:45 -0800 Subject: [PATCH 01/92] 3D and Materials: Expand as Wireframes turns a 3D object into its edges as paths Window > 3D and Materials > Quick Actions > Expand as Wireframes, and the command effect.expandAsWireframes, replace each selected object that has a visible 3D effect (Extrude & Bevel, Extrude & Bevel (Classic), Revolve, Inflate or Rotate) with a group of paths: the edges of the faces the effect draws for the object as it is turned and projected now. - Each edge appears once. Edges that meet end to end form one path, closed where it comes back to its start, and curved edges stay curves. Geometry effects after the 3D effect reshape the edges as they reshape the drawn surface. - The paths have a 0.5 pt black stroke with round joins and no fill. The group keeps the object's id, name, transparency, attributes, slice and text wrap, and is selected. A layer stays a layer holding the edges. - It is one undo step. Nothing selected, no visible 3D effect, a solid that can't be built or is drawn as its source art, or more than 1,000,000 edges in one object is an error, and the document does not change. Docs: docs/mcp.md, docs/three-d.md, docs/effects-parity.md, docs/target-app-parity.md. Fixes #6354 --- crates/effects/src/lib.rs | 4 +- crates/effects/src/revolve.rs | 49 ++++ crates/effects/src/solids.rs | 3 + crates/effects/src/solids/wireframe.rs | 262 +++++++++++++++++++ crates/effects/src/solids/wireframe/tests.rs | 156 +++++++++++ crates/engine/src/cmd.rs | 2 + crates/engine/src/cmd/wireframe.rs | 118 +++++++++ crates/engine/src/cmd/wireframe/tests.rs | 143 ++++++++++ crates/ui-egui/src/panels/tests_three_d.rs | 26 ++ crates/ui-egui/src/panels/three_d.rs | 29 +- docs/effects-parity.md | 2 +- docs/mcp.md | 22 ++ docs/target-app-parity.md | 1 + docs/three-d.md | 13 + 14 files changed, 826 insertions(+), 4 deletions(-) create mode 100644 crates/effects/src/solids/wireframe.rs create mode 100644 crates/effects/src/solids/wireframe/tests.rs create mode 100644 crates/engine/src/cmd/wireframe.rs create mode 100644 crates/engine/src/cmd/wireframe/tests.rs diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 67b4b464fd..1a838fb28f 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -87,8 +87,8 @@ pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_art_in, revolve_opt pub use solids::{ CLASSIC_EXTRUDE, EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options, classic_shading, extrude_options, has_solid, inflate_options, is_three_d, solid_art, solid_art_in, three_d_meshes, three_d_meshes_in, - three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, validate_solid, validate_solid_in, validate_three_d, validate_three_d_at, - validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, + three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, three_d_wireframe_in, validate_solid, validate_solid_in, validate_three_d, + validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use svg_filter::SVG_FILTER; diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 4795e8848a..31377318cc 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -455,6 +455,55 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul Ok(out) } +/// The outline of every face [`revolve_art`] draws for `n` (its symbol instances, blends and clipping +/// groups already resolved), in document coordinates: with the effect on the object, the surface in +/// its top visible fill (else its stroke); with it on fills and strokes, one surface for each of +/// them, reshaped by the geometry effects after each one's Revolve; on a group or layer, one for +/// each member, about one shared axis. +pub(crate) fn revolve_face_paths(n: &Node) -> Result, String> { + let faces = |path: &PathData, b: Rect, options: Revolve| -> Result, String> { + Ok(vectorcraft_three_d::revolve(path, b, options)?.iter().map(vectorcraft_three_d::Face::path).collect()) + }; + if is_container(n) { + let index = + n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE).ok_or("a 3D effect on a group's fill or stroke draws nothing")?; + let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) }; + let mut shapes = members(n, "Revolve")?; + reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes); + let b = union_bounds(&shapes).ok_or("Revolve requires a group holding a shape with at least two anchors")?; + let options = shared_options(&shapes, revolve_options(&merged_params(REVOLVE, &fx.params)))?; + let mut out = vec![]; + for s in shapes.iter().filter(|s| surface_item(&s.items).is_some()) { + out.extend(faces(&s.path, b, options)?); + } + return Ok(out); + } + let (path, _) = object_profile(n, "Revolve")?; + let ctx = GeomContext::of(n); + if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) { + let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) }; + let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx); + if surface_item(&solid_paints(n)).is_none() { + return Err("the revolved object has no fill or stroke".into()); + } + let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?; + return faces(&path, b, revolve_options(&merged_params(REVOLVE, &fx.params))); + } + let path = geometry(&path, &n.appearance.effects, &ctx); + let mut out = vec![]; + for item in n.appearance.items.iter().filter(|i| crate::paints(i)) { + if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) + && let Some(fx) = item.effects().get(index) + { + let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); + let b = p.bounds().ok_or("Revolve requires a shape with at least two anchors")?; + let surface = faces(&p, b, revolve_options(&merged_params(REVOLVE, &fx.params)))?; + out.extend(crate::solids::wireframe::reshaped(surface, item.effects().get(index + 1..).unwrap_or_default(), &ctx)); + } + } + Ok(out) +} + /// [`revolve_meshes`] of group or layer `n`: one mesh for each member, about one shared axis. fn group_meshes(n: &Node, total: &mut usize, limit: usize) -> Result, String> { let index = n diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index ff5b1c9711..f9d3a68ac9 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -22,6 +22,9 @@ pub(crate) use source::{resolve, resolve_for}; pub(crate) mod shadow; +pub(crate) mod wireframe; +pub use wireframe::three_d_wireframe_in; + pub const EXTRUDE: &str = "threeD.extrude"; pub const INFLATE: &str = "threeD.inflate"; pub const ROTATE: &str = "threeD.rotate"; diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs new file mode 100644 index 0000000000..eb74546225 --- /dev/null +++ b/crates/effects/src/solids/wireframe.rs @@ -0,0 +1,262 @@ +//! Expand as Wireframes: the edges of the faces a 3D effect draws, as open and closed paths that +//! can be edited on their own. +use std::collections::{HashMap, HashSet, VecDeque}; + +use vectorcraft_doc::{AppearanceItem, Document, Effect, Node}; +use vectorcraft_geom::{Anchor, CubicBez, PathData, Point, SubPath}; +use vectorcraft_three_d::Facet; + +use super::{ + INFLATE, Profile, ROTATE, facets, geometry, has_solid, inflate_options, is_container, is_three_d, label_of, members, object_profile, region, + reshape_members, resolve, solid, solid_paints, solid_parts, union_bounds, view_options, +}; +use crate::{GeomContext, apply_geometry_with, is_geometry}; + +/// The most distinct edges one object's wireframe may have. +pub(crate) const MAX_WIRE_EDGES: usize = 1_000_000; + +/// The grid points are rounded to before edges are compared, in points: two faces that share an +/// edge give it once. +const GRID: f64 = 1.0 / 1024.0; + +const NO_EFFECT: &str = "the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate)"; + +/// The wireframe of `n`'s 3D effect (Extrude & Bevel, Extrude & Bevel (Classic), Revolve, Inflate +/// or Rotate), in document coordinates: the edges of every face the effect draws for the object as +/// it is turned and projected, each edge once. Edges that meet end to end are joined into one path; +/// a run that comes back to where it started is closed. A curved edge stays a curve. The geometry +/// effects after the object's 3D effect reshape the wireframe as they reshape the drawn art. On a +/// group or layer, the edges of the solids of all its members. An error when `n` has no visible 3D +/// effect, when its solids can't be built, when the canvas draws the source art in their place, when +/// they draw no face, or past [`MAX_WIRE_EDGES`] edges. +pub fn three_d_wireframe_in(doc: Option<&Document>, n: &Node) -> Result, String> { + if !crate::has_revolve(n) && !has_solid(n) { + return Err(NO_EFFECT.into()); + } + let label = n.appearance.effects.iter().find(|e| e.visible && is_three_d(&e.id)).map_or("This effect", |e| label_of(&e.id)); + // The faces come from the effect whose art the canvas draws (Revolve first, as the canvas tries + // it first). When neither draws, past their limits, the canvas shows the source art and there + // is no surface to expand. + let revolve = crate::has_revolve(n) && crate::revolve_art_in(doc, n).is_some(); + if !revolve && !(has_solid(n) && super::solid_art_in(doc, n).is_some()) { + let n = &*resolve(doc, n, label)?; + // The builders' own error says why, when there is one. + if crate::has_revolve(n) { + crate::revolve::revolve_face_paths(n)?; + } else { + solid_face_paths(n)?; + } + return Err(format!("{label} draws the source art for this object instead of its 3D surface")); + } + let n = &*resolve(doc, n, label)?; + let faces = if revolve { crate::revolve::revolve_face_paths(n)? } else { solid_face_paths(n)? }; + let faces = after_geometry(n, faces); + let wire = edges(&faces)?; + if wire.is_empty() { + return Err(format!("{label} draws no faces for this object")); + } + Ok(wire) +} + +/// `faces` reshaped by the visible geometry effects after `n`'s own 3D effect, which act on the +/// projected art as a whole, about the bounds of all the faces. +fn after_geometry(n: &Node, faces: Vec) -> Vec { + let fx = &n.appearance.effects; + let Some(index) = fx.iter().position(|e| e.visible && is_three_d(&e.id)) else { return faces }; + reshaped(faces, fx.get(index + 1..).unwrap_or_default(), &GeomContext::of(n)) +} + +/// `faces` reshaped by the visible geometry effects in `later` (the effects after a 3D effect), +/// which act on the faces as a whole, about their bounds, as they act on the drawn surface. +pub(crate) fn reshaped(faces: Vec, later: &[Effect], ctx: &GeomContext) -> Vec { + let after: Vec = later.iter().filter(|e| e.visible && is_geometry(&e.id)).cloned().collect(); + if after.is_empty() { + return faces; + } + let joined = PathData::new(faces.into_iter().flat_map(|f| f.subpaths).collect()); + let Some(bounds) = joined.bounds() else { return vec![joined] }; + vec![apply_geometry_with(&after, &joined, bounds, ctx)] +} + +/// The outline of every face [`super::solid_art`] draws for `n` (already resolved): with the effect +/// on the object, its solids; with it on fills and strokes, the solid of each; on a group or layer, +/// the solids of its members, about one shared axis. +fn solid_face_paths(n: &Node) -> Result, String> { + if is_container(n) { + let (index, fx) = solid(&n.appearance.effects).ok_or("a 3D effect on a group's fill or stroke draws nothing")?; + let mut shapes = members(n, label_of(&fx.id))?; + reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes); + return facet_paths(fx, &shapes); + } + let label = n.appearance.effects.iter().find(|e| e.visible && is_three_d(&e.id)).map_or("This effect", |e| label_of(&e.id)); + let (path, rule) = object_profile(n, label)?; + let ctx = GeomContext::of(n); + let paints = solid_paints(n); + if let Some((index, fx)) = solid(&n.appearance.effects) { + let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx); + return facet_paths(fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }]); + } + let path = geometry(&path, &n.appearance.effects, &ctx); + let mut out = vec![]; + for item in &paints { + if let Some((index, fx)) = solid(item.effects()) { + let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); + let faces = facet_paths(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?; + out.extend(reshaped(faces, item.effects().get(index + 1..).unwrap_or_default(), &ctx)); + } + } + Ok(out) +} + +/// The outlines of the facets effect `fx` builds from `shapes`, as `art_for` builds them: +/// Rotate turns every fill and stroke; Extrude & Bevel and Inflate build a solid from each shape's +/// top fill and from the outline of each stroke. A stroke whose solid is past the limits is left out, +/// as it is left out of the drawn art. +fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result, String> { + let label = label_of(&fx.id); + let b = union_bounds(shapes).ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; + let p = crate::merged_params(&fx.id, &fx.params); + let mut out = vec![]; + if fx.id == ROTATE { + for s in shapes { + for item in &s.items { + let (area, rule) = region(&s.path, s.rule, item); + out.extend(facets(ROTATE, &area, rule, b, &p)?.iter().map(Facet::path)); + } + } + return Ok(out); + } + let (parts, bounds) = solid_parts(shapes, b); + let built: Vec, String>> = if fx.id == INFLATE { + let shapes: Vec<(&PathData, vectorcraft_geom::FillRule)> = parts.iter().map(|(_, _, area, rule, ..)| (area, *rule)).collect(); + vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p)) + } else { + parts.iter().map(|(_, _, area, rule, ..)| facets(&fx.id, area, *rule, bounds, &p)).collect() + }; + for ((item, ..), f) in parts.iter().zip(built) { + match f { + Ok(f) => out.extend(f.iter().map(Facet::path)), + Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {} + Err(e) => return Err(e), + } + } + Ok(out) +} + +/// A point on the [`GRID`]. +type Snap = (i64, i64); + +fn snap(p: Point) -> Snap { + ((p.x / GRID).round() as i64, (p.y / GRID).round() as i64) +} + +fn reversed(c: CubicBez) -> CubicBez { + CubicBez::new(c.p3, c.p2, c.p1, c.p0) +} + +/// The edges of `faces`, each once, joined end to end into paths ([`three_d_wireframe_in`]). +pub(crate) fn edges(faces: &[PathData]) -> Result, String> { + let mut seen: HashSet<[Snap; 4]> = HashSet::new(); + let mut segments: Vec = vec![]; + for sp in faces.iter().flat_map(|f| &f.subpaths) { + for i in 0..sp.segment_count() { + let mut c = sp.segment(i); + if sp.segment_is_line(i) { + c = CubicBez::new(c.p0, c.p0, c.p3, c.p3); + } + if [c.p0, c.p1, c.p2, c.p3].iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) { + continue; + } + let key = [c.p0, c.p1, c.p2, c.p3].map(snap); + // A segment that starts and ends on one grid point with its handles there too draws nothing. + if key.iter().all(|k| *k == key[0]) { + continue; + } + let back = [key[3], key[2], key[1], key[0]]; + if seen.contains(&back) || !seen.insert(key) { + continue; + } + if segments.len() >= MAX_WIRE_EDGES { + return Err(format!("the wireframe has more than {MAX_WIRE_EDGES} edges")); + } + segments.push(c); + } + } + Ok(chain(&segments)) +} + +/// `segments` joined end to end: each path starts at the first segment not yet used and takes in +/// the unused segments that meet its ends until none is left at either end, or it closes. +fn chain(segments: &[CubicBez]) -> Vec { + let mut at: HashMap> = HashMap::new(); + for (i, c) in segments.iter().enumerate() { + at.entry(snap(c.p0)).or_default().push(i); + if snap(c.p3) != snap(c.p0) { + at.entry(snap(c.p3)).or_default().push(i); + } + } + let mut used = vec![false; segments.len()]; + // The next unused segment at `point`, turned to leave it (`leave`) or to arrive at it. + let mut take = |used: &mut Vec, point: Snap, leave: bool| -> Option { + let list = at.get_mut(&point)?; + while let Some(&i) = list.last() { + if used.get(i).copied().unwrap_or(true) { + list.pop(); + continue; + } + if let Some(u) = used.get_mut(i) { + *u = true; + } + let c = *segments.get(i)?; + let starts = snap(c.p0) == point; + return Some(if starts == leave { c } else { reversed(c) }); + } + None + }; + let mut out = vec![]; + for (i, first) in segments.iter().enumerate() { + if used.get(i).copied().unwrap_or(true) { + continue; + } + if let Some(u) = used.get_mut(i) { + *u = true; + } + let mut run: VecDeque = VecDeque::from([*first]); + // A path of one anchor has no segment (`SubPath::segment_count`), so a run of one segment + // that comes back to its start stays open, with its two ends on one point. + let closes = |run: &VecDeque| run.len() > 1 && run.front().zip(run.back()).is_some_and(|(f, b)| snap(f.p0) == snap(b.p3)); + while !closes(&run) { + let Some(end) = run.back().map(|c| snap(c.p3)) else { break }; + let Some(c) = take(&mut used, end, true) else { break }; + run.push_back(c); + } + while !closes(&run) { + let Some(start) = run.front().map(|c| snap(c.p0)) else { break }; + let Some(c) = take(&mut used, start, false) else { break }; + run.push_front(c); + } + out.push(PathData::single(subpath(&run, closes(&run)))); + } + out +} + +/// `run`, segments that meet end to end, as one subpath; a closed one ends at its first anchor. +fn subpath(run: &VecDeque, closed: bool) -> SubPath { + let mut anchors: Vec = vec![]; + if let Some(first) = run.front() { + let h_in = if closed { run.back().map_or(first.p0, |b| b.p2) } else { first.p0 }; + anchors.push(Anchor::with_handles(first.p0, h_in, first.p1)); + } + for (k, c) in run.iter().enumerate() { + let h_out = run.get(k + 1).map_or(c.p3, |next| next.p1); + anchors.push(Anchor::with_handles(c.p3, c.p2, h_out)); + } + // A closed run's last anchor is its first, whose in handle already comes from the last segment. + if closed { + anchors.pop(); + } + SubPath::new(anchors, closed) +} + +#[cfg(test)] +mod tests; diff --git a/crates/effects/src/solids/wireframe/tests.rs b/crates/effects/src/solids/wireframe/tests.rs new file mode 100644 index 0000000000..980e67bbeb --- /dev/null +++ b/crates/effects/src/solids/wireframe/tests.rs @@ -0,0 +1,156 @@ +use serde_json::{Value, json}; +use vectorcraft_doc::color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Node, NodeId}; +use vectorcraft_geom::{BezPath, Rect}; + +use super::*; + +fn square(x: f64, y: f64, size: f64) -> PathData { + let mut b = BezPath::new(); + b.move_to((x, y)); + b.line_to((x + size, y)); + b.line_to((x + size, y + size)); + b.line_to((x, y + size)); + b.close_path(); + PathData::from_bezpath(&b) +} + +/// A filled 100 pt square without a stroke, with effect `id` and `params`. +fn solid_square(id: &str, params: Value) -> Node { + let mut n = Node::path(NodeId(1), square(100.0, 100.0, 100.0), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + n.appearance.effects.push(crate::new_effect(id, ¶ms).unwrap()); + n +} + +/// Every segment of `wire`, each as its two ends on the grid. +fn segments(wire: &[PathData]) -> Vec<(Snap, Snap)> { + wire.iter() + .flat_map(|p| &p.subpaths) + .flat_map(|sp| (0..sp.segment_count()).map(move |i| sp.segment(i))) + .map(|c| (snap(c.p0), snap(c.p3))) + .collect() +} + +fn bounds(paths: &[PathData]) -> Rect { + paths.iter().filter_map(PathData::bounds).reduce(|a, b| a.union(b)).unwrap() +} + +/// The paths of the faces `art` draws (every leaf of the art). +fn face_paths(art: &Node) -> Vec { + let mut out = vec![]; + art.walk(&mut |c| out.extend(c.path_data().cloned())); + out +} + +#[test] +fn two_faces_that_share_an_edge_give_it_once() { + let wire = edges(&[square(0.0, 0.0, 10.0), square(10.0, 0.0, 10.0)]).unwrap(); + let segs = segments(&wire); + assert_eq!(segs.len(), 7, "{segs:?}"); + for (i, a) in segs.iter().enumerate() { + for b in segs.iter().skip(i + 1) { + assert!(a != b && (a.1, a.0) != *b, "{a:?} twice"); + } + } +} + +#[test] +fn a_loop_of_edges_is_one_closed_path_and_a_curve_stays_a_curve() { + let wire = edges(&[square(0.0, 0.0, 10.0)]).unwrap(); + assert_eq!(wire.len(), 1); + assert!(wire[0].subpaths[0].closed); + assert_eq!(wire[0].subpaths[0].anchors.len(), 4); + + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + b.curve_to((0.0, 10.0), (10.0, 10.0), (10.0, 0.0)); + b.close_path(); + let wire = edges(&[PathData::from_bezpath(&b)]).unwrap(); + let curved = wire.iter().flat_map(|p| &p.subpaths).any(|sp| (0..sp.segment_count()).any(|i| !sp.segment_is_line(i))); + assert!(curved, "{wire:?}"); +} + +/// A single curve that comes back to its start keeps its segment: it stays open, with two anchors on +/// one point, since a path of one anchor has no segment. +#[test] +fn a_single_curve_that_comes_back_to_its_start_keeps_its_segment() { + let p = Point::new(0.0, 0.0); + let anchors = vec![Anchor::with_handles(p, p, Point::new(0.0, 10.0)), Anchor::with_handles(p, Point::new(10.0, 10.0), p)]; + let face = PathData::single(SubPath::new(anchors, false)); + assert_eq!(face.subpaths[0].segment_count(), 1); + let wire = edges(&[face]).unwrap(); + assert_eq!(wire.len(), 1); + let sp = &wire[0].subpaths[0]; + assert_eq!(sp.segment_count(), 1, "{sp:?}"); + assert!(!sp.segment_is_line(0)); +} + +#[test] +fn edges_without_length_or_with_non_finite_points_are_left_out() { + let point = PathData::single(SubPath::polyline(&[Point::new(5.0, 5.0), Point::new(5.0, 5.0)], false)); + let bad = PathData::single(SubPath::polyline(&[Point::new(f64::NAN, 0.0), Point::new(1.0, 1.0)], false)); + assert!(edges(&[point, bad]).unwrap().is_empty()); + assert!(edges(&[]).unwrap().is_empty()); +} + +#[test] +fn a_box_turned_off_axis_shows_the_nine_edges_of_its_three_faces() { + let n = solid_square(super::super::EXTRUDE, json!({})); + let wire = three_d_wireframe_in(None, &n).unwrap(); + assert_eq!(segments(&wire).len(), 9, "{wire:?}"); + // The wireframe covers what the effect draws, in the same place. + let art = super::super::solid_art(&n).unwrap(); + let (w, a) = (bounds(&wire), bounds(&face_paths(&art))); + for (x, y) in [(w.x0, a.x0), (w.y0, a.y0), (w.x1, a.x1), (w.y1, a.y1)] { + assert!((x - y).abs() < 1e-6, "{w:?} {a:?}"); + } +} + +#[test] +fn the_classic_wireframe_surface_expands_to_all_twelve_edges() { + let n = solid_square(super::super::CLASSIC_EXTRUDE, json!({"surface": "wireframe"})); + assert_eq!(segments(&three_d_wireframe_in(None, &n).unwrap()).len(), 12); +} + +#[test] +fn revolve_inflate_and_rotate_expand_too() { + for id in [crate::REVOLVE, super::super::INFLATE, ROTATE] { + let wire = three_d_wireframe_in(None, &solid_square(id, json!({}))).unwrap_or_else(|e| panic!("{id}: {e}")); + assert!(!wire.is_empty(), "{id}"); + assert!(wire.iter().flat_map(|p| &p.subpaths).flat_map(|sp| &sp.anchors).all(|a| a.p.x.is_finite() && a.p.y.is_finite()), "{id}"); + } + // Rotate is one flat face: its outline. + let plane = three_d_wireframe_in(None, &solid_square(ROTATE, json!({}))).unwrap(); + assert_eq!(segments(&plane).len(), 4); +} + +/// A geometry effect after a fill's 3D effect moves that fill's edges, as it moves its drawn surface. +#[test] +fn a_geometry_effect_after_a_fill_s_3d_effect_reshapes_its_edges() { + for id in [super::super::EXTRUDE, crate::REVOLVE] { + let mut n = solid_square(id, json!({})); + let fx = n.appearance.effects.remove(0); + for item in &mut n.appearance.items { + if matches!(item, AppearanceItem::Fill(_)) { + item.effects_mut().push(fx.clone()); + } + } + let plain = bounds(&three_d_wireframe_in(None, &n).unwrap_or_else(|e| panic!("{id}: {e}"))); + for item in &mut n.appearance.items { + if matches!(item, AppearanceItem::Fill(_)) { + item.effects_mut().push(crate::new_effect("distort.transform", &json!({"moveH": 50.0})).unwrap()); + } + } + let moved = bounds(&three_d_wireframe_in(None, &n).unwrap()); + assert!((moved.x0 - plain.x0 - 50.0).abs() < 1e-6 && (moved.y0 - plain.y0).abs() < 1e-6, "{id}: {plain:?} {moved:?}"); + } +} + +#[test] +fn an_object_without_a_visible_3d_effect_is_an_error() { + let mut n = solid_square(super::super::EXTRUDE, json!({})); + n.appearance.effects[0].visible = false; + assert!(three_d_wireframe_in(None, &n).unwrap_err().contains("no 3D effect")); + n.appearance.effects.clear(); + assert!(three_d_wireframe_in(None, &n).is_err()); +} diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 72e33b8993..f773c8716d 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -105,6 +105,7 @@ mod variablecmds; pub(crate) mod views; pub mod wand; pub mod webexport; +mod wireframe; mod xform; use serde::Serialize; @@ -308,6 +309,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(draw2::specs()); v.extend(xform::specs()); v.extend(effectcmd::specs()); + v.extend(wireframe::specs()); v.extend(pathops::specs()); v.extend(compoundshape::specs()); v.extend(typecmd::specs()); diff --git a/crates/engine/src/cmd/wireframe.rs b/crates/engine/src/cmd/wireframe.rs new file mode 100644 index 0000000000..5e7dd3f0d4 --- /dev/null +++ b/crates/engine/src/cmd/wireframe.rs @@ -0,0 +1,118 @@ +//! Window › 3D and Materials › Quick Actions › Expand as Wireframes: each object with a 3D effect +//! becomes a group of stroked paths, the edges of the faces its effect draws. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::appearance::AppearanceItem; +use vectorcraft_doc::{Appearance, LineJoin, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_render::effects; + +use super::{CommandSpec, Result, Session, bad, checked_ids_or_selection, cmd, has_doc}; + +const C: &str = "effect.expandAsWireframes"; + +/// The name of the undo step. +const LABEL: &str = "Expand as Wireframes"; + +/// The weight of a wireframe edge's stroke, in points (VectorCraft's own). +pub const WIRE_WEIGHT: f64 = 0.5; + +pub fn specs() -> Vec { + vec![cmd!( + "effect.expandAsWireframes", + "Expand as Wireframes", + [], + None, + "{ids?: [..] (default: the selected objects)} replace each object that has a visible 3D effect (Extrude & Bevel, Extrude & Bevel (Classic), Revolve, Inflate or Rotate, on the object or on its fills and strokes) with a group of paths: the edges of the faces the effect draws, as the object is turned and projected now, each edge once (edges that meet end to end make one path, closed where it comes back to its start; curved edges stay curves; geometry effects after the 3D effect, on the object or on a fill or stroke, reshape them too), stroked 0.5 pt black with round joins and no fill (VectorCraft's own); the group takes the object's place, id, name, opacity, blending mode, opacity mask, attributes, slice and text wrap and is selected (a layer stays a layer holding the edges, which are selected); objects without a visible 3D effect are left as they are; one undo step. An error, with nothing changed, when no object is selected or given, when none has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges → {ids}", + has_doc, + expand_as_wireframes + )] +} + +/// A wireframe edge's appearance: a 0.5 pt black stroke with round joins and no fill. +fn wire_appearance() -> Appearance { + let mut stroke = StrokeLayer::new(Paint::solid(Color::BLACK), WIRE_WEIGHT); + stroke.join = LineJoin::Round; + Appearance { items: vec![AppearanceItem::Stroke(stroke)], ..Default::default() } +} + +fn expand_as_wireframes(s: &mut Session, p: &Value) -> Result { + let roots = checked_ids_or_selection(s, p, C)?; + if roots.is_empty() { + return Err(bad(C, "select an object with a 3D effect, or give its id in ids")); + } + // Every wireframe is built before the edit, so a failure leaves the document and its history as + // they are. + let d = &s.doc()?.doc; + let mut wires = vec![]; + for id in roots { + let Some(n) = d.node(id) else { continue }; + if !effects::has_revolve(n) && !effects::has_solid(n) { + continue; + } + let wire = effects::three_d_wireframe_in(Some(d), n).map_err(|e| bad(C, format!("object {}: {e}", id.0)))?; + wires.push((id, wire)); + } + if wires.is_empty() { + return Err(bad(C, "no selected or given object has a visible 3D effect")); + } + let ids: Vec = s.edit(LABEL, |d, sel| { + let mut out = vec![]; + let mut selected = vec![]; + for (id, wire) in wires { + let Some(source) = d.node(id) else { continue }; + if matches!(source.kind, NodeKind::Layer { .. }) { + // A layer stays a layer: its members become the edges, and its 3D effect is removed. + let mut layer = source.clone(); + let children: Vec> = wire.into_iter().map(|path| Arc::new(Node::path(d.alloc_id(), path, wire_appearance()))).collect(); + selected.extend(children.iter().map(|c| c.id)); + if let Some(members) = layer.children_mut() { + *members = children; + } + layer.appearance = Default::default(); + if let Some(slot) = d.node_mut(id) { + *slot = layer; + out.push(id); + } + continue; + } + // The group keeps the object's name, visibility, lock, bounding box angle, transparency + // (opacity, blending mode, isolation, knockout and opacity mask), attributes, slice and text + // wrap. + let mut group = Node::group(id, vec![]); + group.name = source.name.clone(); + group.visible = source.visible; + group.locked = source.locked; + group.opacity = source.opacity; + group.blend = source.blend; + group.isolate = source.isolate; + group.knockout = source.knockout; + group.knockout_shape = source.knockout_shape; + group.bbox_angle = source.bbox_angle; + let extras = source.extras(); + let (mask, attrs, slice, wrap) = (extras.mask.clone(), extras.attrs.clone(), extras.slice.clone(), extras.wrap); + if mask.is_some() || attrs.is_some() || slice.is_some() || wrap.is_some() { + let mut e = group.extras_mut(); + e.mask = mask; + e.attrs = attrs; + e.slice = slice; + e.wrap = wrap; + } + let children = wire.into_iter().map(|path| Arc::new(Node::path(d.alloc_id(), path, wire_appearance()))).collect(); + group.kind = NodeKind::Group { children, clip: false }; + if let Some(slot) = d.node_mut(id) { + *slot = group; + out.push(id); + selected.push(id); + } + } + sel.set(selected); + Ok(out) + })?; + Ok(json!({ "ids": ids.iter().map(|id| id.0).collect::>() })) +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/cmd/wireframe/tests.rs b/crates/engine/src/cmd/wireframe/tests.rs new file mode 100644 index 0000000000..69e83d4b4a --- /dev/null +++ b/crates/engine/src/cmd/wireframe/tests.rs @@ -0,0 +1,143 @@ +#![allow(clippy::unwrap_used, clippy::panic)] +use serde_json::{Value, json}; +use vectorcraft_doc::appearance::AppearanceItem; +use vectorcraft_doc::{NodeId, NodeKind}; + +use crate::Session; + +/// A new document with a filled 100 pt square; `effect` is applied to it when given. +fn setup(effect: Option<&str>) -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":400,"height":400})).unwrap(); + let id = NodeId(s.execute("path.create", &json!({"d":"M100 100L200 100L200 200L100 200Z"})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"color":"#338ad6"})).unwrap(); + if let Some(effect) = effect { + s.execute("effect.apply", &json!({"effect":effect,"item":null,"params":{}})).unwrap(); + } + (s, id) +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn run(s: &mut Session, p: Value) -> crate::Result { + s.execute("effect.expandAsWireframes", &p) +} + +#[test] +fn a_3d_object_becomes_a_selected_group_of_stroked_edges_in_one_undo_step() { + for effect in ["threeD.extrude", "threeD.classicExtrude", "threeD.revolve", "threeD.inflate", "threeD.rotate"] { + let (mut s, id) = setup(Some(effect)); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().name = Some("Box".into()); + let before = s.doc().unwrap().doc.clone(); + let history = undo_len(&s); + let out = run(&mut s, json!({})).unwrap_or_else(|e| panic!("{effect}: {e}")); + assert_eq!(out["ids"], json!([id.0]), "{effect}"); + let st = s.doc().unwrap(); + let group = st.doc.node(id).unwrap(); + let NodeKind::Group { children, clip: false } = &group.kind else { panic!("{effect}: a group") }; + assert!(!children.is_empty(), "{effect}"); + assert_eq!(group.name.as_deref(), Some("Box"), "{effect}"); + assert!(group.appearance.effects.is_empty(), "{effect}: the effect is gone"); + for c in children { + assert!(c.path_data().is_some(), "{effect}: paths only"); + let [AppearanceItem::Stroke(stroke)] = c.appearance.items.as_slice() else { panic!("{effect}: one stroke and no fill") }; + assert_eq!(stroke.width, super::WIRE_WEIGHT); + } + assert_eq!(st.selection.objects, vec![id], "{effect}"); + assert_eq!(undo_len(&s), history + 1, "{effect}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before, "{effect}"); + } +} + +#[test] +fn the_group_keeps_the_object_s_transparency() { + let (mut s, id) = setup(Some("threeD.extrude")); + s.execute("transparency.set", &json!({"opacity": 50, "knockout": "on"})).unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().bbox_angle = 0.25; + let before = s.doc().unwrap().doc.node(id).unwrap().clone(); + run(&mut s, json!({})).unwrap(); + let group = s.doc().unwrap().doc.node(id).unwrap().clone(); + assert!((group.opacity - 0.5).abs() < 1e-6, "{}", group.opacity); + assert_eq!((group.opacity, group.blend, group.knockout), (before.opacity, before.blend, before.knockout)); + assert_eq!(group.bbox_angle, 0.25, "the bounding box keeps its angle"); +} + +#[test] +fn a_layer_with_a_3d_effect_stays_a_layer_holding_the_edges() { + let (mut s, _) = setup(None); + let layer = s.doc().unwrap().doc.layers[0].id; + s.execute("effect.apply", &json!({"ids":[layer.0],"effect":"threeD.extrude","item":null,"params":{}})).unwrap(); + let history = undo_len(&s); + let out = run(&mut s, json!({"ids":[layer.0]})).unwrap(); + assert_eq!(out["ids"], json!([layer.0])); + let st = s.doc().unwrap(); + let node = st.doc.node(layer).unwrap(); + assert!(matches!(node.kind, NodeKind::Layer { .. })); + assert!(node.appearance.effects.is_empty()); + let children = node.children().unwrap(); + assert!(!children.is_empty() && children.iter().all(|c| c.path_data().is_some())); + assert_eq!(st.selection.objects, children.iter().map(|c| c.id).collect::>()); + assert_eq!(undo_len(&s), history + 1); +} + +/// The number of segments in the wireframe group `id`. +fn segments(s: &Session, id: NodeId) -> usize { + let st = s.doc().unwrap(); + st.doc.node(id).unwrap().children().unwrap().iter().flat_map(|c| &c.path_data().unwrap().subpaths).map(|sp| sp.segment_count()).sum() +} + +#[test] +fn a_box_expands_to_the_nine_edges_of_its_three_faces() { + let (mut s, id) = setup(Some("threeD.extrude")); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + run(&mut s, json!({"ids":[id.0]})).unwrap(); + assert_eq!(segments(&s, id), 9); + // A stroke is outlined before the effect, so its own solid adds its edges. + let (mut s, id) = setup(Some("threeD.extrude")); + run(&mut s, json!({"ids":[id.0]})).unwrap(); + assert!(segments(&s, id) > 9); +} + +#[test] +fn objects_without_a_3d_effect_are_left_and_none_at_all_is_an_error() { + let (mut s, plain) = setup(None); + let history = undo_len(&s); + let before = s.doc().unwrap().doc.clone(); + let e = run(&mut s, json!({})).unwrap_err().to_string(); + assert!(e.contains("no selected or given object has a visible 3D effect"), "{e}"); + assert_eq!(undo_len(&s), history, "no undo step when nothing changes"); + assert_eq!(s.doc().unwrap().doc, before); + + // A hidden 3D effect draws nothing to expand. + s.execute("effect.apply", &json!({"effect":"threeD.extrude","item":null,"params":{}})).unwrap(); + s.execute("effect.setParams", &json!({"index":0,"item":null,"visible":false})).unwrap(); + assert!(run(&mut s, json!({})).is_err()); + + // With nothing selected. + s.execute("select.none", &json!({})).unwrap(); + let e = run(&mut s, json!({})).unwrap_err().to_string(); + assert!(e.contains("select an object with a 3D effect"), "{e}"); + + // A second object with a 3D effect expands; the plain one is left as it is. + let other = NodeId(s.execute("path.create", &json!({"d":"M250 250L300 250L300 300Z"})).unwrap()["id"].as_u64().unwrap()); + s.execute("effect.remove", &json!({"ids":[plain.0],"index":0,"item":null})).unwrap(); + s.execute("paint.setFill", &json!({"ids":[other.0],"color":"#ff0000"})).unwrap(); + s.execute("effect.apply", &json!({"ids":[other.0],"effect":"threeD.extrude","item":null,"params":{}})).unwrap(); + let kept = s.doc().unwrap().doc.node(plain).unwrap().clone(); + let out = run(&mut s, json!({"ids":[plain.0, other.0]})).unwrap(); + assert_eq!(out["ids"], json!([other.0])); + assert_eq!(s.doc().unwrap().doc.node(plain).unwrap(), &kept); +} + +#[test] +fn bad_ids_are_errors() { + let (mut s, _) = setup(Some("threeD.extrude")); + for p in [json!({"ids":[9999]}), json!({"ids":"x"}), json!({"ids":[-1]})] { + let history = undo_len(&s); + assert!(run(&mut s, p.clone()).is_err(), "{p}"); + assert_eq!(undo_len(&s), history, "{p}"); + } +} diff --git a/crates/ui-egui/src/panels/tests_three_d.rs b/crates/ui-egui/src/panels/tests_three_d.rs index 3c94bf88a5..e8bfede3fd 100644 --- a/crates/ui-egui/src/panels/tests_three_d.rs +++ b/crates/ui-egui/src/panels/tests_three_d.rs @@ -401,6 +401,32 @@ fn a_mixed_revolve_cap_shows_a_dash() { assert_eq!(undo_len(&app), history); } +/// Quick Actions › Expand as Wireframes shows for every object type, and turns the selected 3D +/// object into a group of stroked edges in one undo step. +#[test] +fn expand_as_wireframes_replaces_the_object_with_its_edges() { + for kind in ["extrude", "inflate", "revolve", "plane"] { + let mut app = app(); + set_type(&mut app, kind).unwrap(); + let text = painted_text(&mut app, show); + assert!(text.contains("Quick Actions") && text.contains("Expand as Wireframes"), "{kind}: {text}"); + let id = app.session.active().unwrap().selection.objects[0]; + let history = undo_len(&app); + super::expand_as_wireframes(&mut app).unwrap(); + assert_eq!(undo_len(&app), history + 1, "{kind}"); + let st = app.session.active().unwrap(); + assert!(matches!(st.doc.node(id).unwrap().kind, vectorcraft_doc::NodeKind::Group { .. }), "{kind}"); + assert_eq!(st.selection.objects, vec![id], "{kind}"); + // The group has no 3D effect: the panel offers the object types again. + assert!(target(&app).is_none(), "{kind}"); + } + // Without a 3D effect in the selection, nothing changes. + let mut app = app(); + let history = undo_len(&app); + assert!(super::expand_as_wireframes(&mut app).is_err()); + assert_eq!(undo_len(&app), history); +} + #[test] fn changed_settings_lists_only_new_values() { let before = json!({"depth": 50.0, "cap": true, "bevel": "none"}); diff --git a/crates/ui-egui/src/panels/three_d.rs b/crates/ui-egui/src/panels/three_d.rs index 25dfaa6842..5d4763090e 100644 --- a/crates/ui-egui/src/panels/three_d.rs +++ b/crates/ui-egui/src/panels/three_d.rs @@ -7,11 +7,12 @@ use egui::Ui; use serde_json::{Map, Value, json}; use vectorcraft_doc::{Effect, Node}; -use vectorcraft_effects::{THREE_D_TYPES, is_three_d}; +use vectorcraft_effects::{CLASSIC_EXTRUDE, EXTRUDE, INFLATE, THREE_D_TYPES, is_three_d}; use super::{live_run, pstate, set_pstate}; use crate::VectorcraftApp; use crate::state::Dialog; +use crate::theme::Tokens; use crate::widgets::{self, Live, dim_label}; /// The panel's id (`window.panel`). @@ -242,6 +243,32 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let changed = changed_settings(&before, &after); let dragging = ctx.input(|i| i.pointer.any_down()); send(app, &ctx, changed, dragging); + if quick_actions_shown(&d) { + ui.add_space(10.0); + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(tl!("Quick Actions")).font(crate::theme::semibold(13.0)).color(t.text)); + let tip = tl!("Replace the selected 3D objects with the edges of their faces, as stroked paths."); + if ui.button(tl!("Expand as Wireframes")).on_hover_text(tip).clicked() { + finish(app, &ctx); + if let Err(e) = expand_as_wireframes(app) { + app.ui.status = e; + } + } + } +} + +/// Does the panel show its Quick Actions? On the Object tab of the effects that have one, and on +/// the first tab of the others (Revolve, Rotate), never on Lighting. +fn quick_actions_shown(d: &Dialog) -> bool { + let effect = d.str("__effect"); + let object_tab = [EXTRUDE, INFLATE, CLASSIC_EXTRUDE].contains(&effect.as_str()); + !crate::dialogs::revolve_gizmo::lighting(d) && (!object_tab || d.str("__tab") != "rotation") +} + +/// Quick Actions › Expand as Wireframes: each selected object with a 3D effect becomes a group of +/// the edges of its faces (`effect.expandAsWireframes`), one undo step. +pub(crate) fn expand_as_wireframes(app: &mut VectorcraftApp) -> Result<(), String> { + app.run("effect.expandAsWireframes", json!({})).map(|_| ()) } /// Sets to null each setting in `fields` (the first selected object's 3D effect) that the 3D effect diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 2602b78bc4..d9752aff51 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -180,7 +180,7 @@ VectorCraft choices; an original approximation, never compared with another appl |---|---| | Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | | Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | -| Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); no materials, soft shadows or ray tracing | +| Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect, and Quick Actions › Expand as Wireframes ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); no materials, soft shadows or ray tracing | | 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic), Rotate (Classic) and Map Art: none | | Turntable (30.0 beta) | none | | Export 3D objects (OBJ, USDA, glTF) | initial: Asset Export's Export 3D Object collects an object with Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, and Export writes its solids as a USDA (the default), USDZ, glTF or OBJ mesh with a material in each solid's paint color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`); no textures, map art or shading ([3D export help](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)) | diff --git a/docs/mcp.md b/docs/mcp.md index da260b6823..98b64d58f6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3094,6 +3094,28 @@ checked as that effect's. It is one undo step. Any other `threeDType` value, or {"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"threeDType":"inflate","params":{"volume":70}}}} ``` +## Expand as Wireframes + +`effect.expandAsWireframes {ids?}` (**Window › 3D and Materials** › Quick Actions › Expand as Wireframes) replaces +each selected object, or each object in `ids`, that has a visible 3D effect (Extrude & Bevel, Extrude & Bevel +(Classic), Revolve, Inflate or Rotate, on the object or on its fills and strokes) with a group of paths: the edges of +the faces the effect draws for the object as it is turned and projected now +([three-d.md](three-d.md#3d-and-materials-panel)). Each edge appears once; edges that meet end to end form one +path, closed where it comes back to its start, and curved edges stay curves. Geometry effects after a 3D effect, +on the object or on a fill or stroke, reshape its edges as they reshape the drawn surface. The paths have a 0.5 pt +black stroke with round joins and no fill (VectorCraft's own choice). The group keeps the object's place, id, name, +visibility, lock, opacity, blending mode, knockout, opacity mask, attributes, slice and text wrap, and the groups +are selected. +A layer in `ids` stays a layer: its members are replaced by the edges, which are selected. Objects without a visible 3D effect do not change. It is one undo step and +returns `{ids}`, the groups. It is an error, and nothing changes, when nothing is selected or given, when no object +has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than +1,000,000 edges. + +```json +{"name":"run_command","arguments":{"command":"effect.expandAsWireframes","params":{}}} +{"name":"run_command","arguments":{"command":"effect.expandAsWireframes","params":{"ids":[12]}}} +``` + ## Extrude & Bevel, Inflate and Rotate `effect.apply {effect: "threeD.extrude" | "threeD.inflate" | "threeD.rotate", params: {...}, ids?, item?}` applies diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 18d50447ff..b3276d581b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -560,6 +560,7 @@ since 2026-10-01. - 3D lighting presets: `crates/effects/src/lighting_presets.rs`, `crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs` and the pointer tests in `crates/ui-egui/src/dialogs/revolve/tests.rs` cover recognition, atomic targeting, native round trips, preview, Cancel, reset and Undo. - 3D shadows: Revolve, Extrude & Bevel, Inflate and Rotate take a Shadows switch with Position (Behind Object or Below Object), Distance from Object (0–100 %) and Shadow Bounds (10–400 %) on the Lighting tab ([three-d.md](three-d.md#shadows)); the first light casts one hard-edged vector shadow, drawn behind the solids, in exports and kept by Expand Appearance. Evidence: `crates/three-d/src/shadow/tests.rs`, `crates/effects/src/solids/tests_shadow.rs`, `effectcmd::tests::a_3d_shadow_is_one_undo_step_saved_exported_and_expanded`. Softness and the shadows of lights after the first are not built. Scores unchanged. - Window › 3D and Materials panel: `crates/ui-egui/src/panels/three_d.rs` applies a 3D effect by object type (Plane, Extrude, Revolve, Inflate), changes an applied one in place (`effect.setParams` `threeDType`) and edits it with the options dialog's Object, Rotation and Lighting controls, one undo step per change; `crates/engine/src/cmd/effectcmd/three_d_type_tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. No Materials tab. +- 3D and Materials › Quick Actions › Expand as Wireframes (#6354): `effect.expandAsWireframes` replaces each selected 3D object with a group of 0.5 pt black stroked paths, the edges of the faces its effect draws, each edge once and joined end to end, curves kept, in one undo step ([three-d.md](three-d.md#3d-and-materials-panel)); `crates/effects/src/solids/wireframe/tests.rs`, `crates/engine/src/cmd/wireframe/tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. Scores and hours unchanged. - 3D effects on clipping groups, blends and symbol instances (#6140, #5725): every 3D type builds from the art the object shows (the art inside the clipping path, the blend's keys and steps, the symbol's art where the instance places it), alone or in a group, on the canvas, in exports, Object › Rasterize and `assets.add3D`; an image is refused with a pointer to Object › Image Trace › Make. `crates/effects/src/solids/tests_sources.rs`, `crates/engine/src/cmd/effectcmd/three_d_source_tests.rs` and `fx::tests::a_symbol_instance_a_blend_and_a_clipping_group_draw_their_solids` cover it. Scores and estimates unchanged. - **Status-bar pointer readout (2026-10-18):** uses the same effective ruler origin as the position fields, then formats the relative X/Y in General units. The absolute pointer position, document, selection, Undo/Redo and saved state are preserved. Three `chrome::tests::status_coordinates_*` painted-text regressions cover zero/positive/negative origins, unit conversion, global/artboard mode, active artboard, reset and missing pointer/document. Original headless status-bar captures were reviewed before and after the change. Scores and hours are unchanged. diff --git a/docs/three-d.md b/docs/three-d.md index 1026a9e1be..283d4a3632 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -51,6 +51,19 @@ pages describe it. unavailable while a setting of a single light differs between the selected effects. - Each change is one undo step. Dragging a value or a slider previews it on the artboard and records one step when the pointer is released; a typed value is recorded when it is entered. Drawing the panel records nothing. +- **Quick Actions › Expand as Wireframes** sits under the Object tab (under the first tab for Revolve and Rotate, + which have no Object tab). It replaces each selected object that has a 3D effect with a group of paths, the + edges of the faces the effect draws for the object as it is turned and projected now (`effect.expandAsWireframes`), + as the public [Render 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/render-3d-vector-artwork.html) + page describes for converting the wireframe into editable paths. Each edge appears once: edges that meet end to + end form one path, closed where it comes back to its start, and curved edges stay curves. A face partly covered + by another face gives its whole outline. Faces turned away from the viewer are included where the effect draws + them (Draw Hidden Faces and the Wireframe surface of Extrude & Bevel (Classic)). Geometry effects after a 3D + effect, on the object or on a fill or stroke, reshape the edges as they reshape the drawn art. Shadows are not + part of the wireframe. The paths have a 0.5 pt black stroke with round joins and no fill, which is VectorCraft's + own choice. The group keeps the object's place, id, name, opacity, blending mode, opacity mask, attributes + and text wrap and is selected; objects without a visible 3D effect do not change. It is one undo step, and an error changes nothing + when no selected object has a visible 3D effect. - The Lighting tab also holds the Shadows switch and its settings ([Shadows](#shadows)). The Materials tab and ray-traced rendering are not built (see below). The rotation and light rings on the artboard are part of the options dialog only. From a90a803cfba2856f748a0d9132bbe4f19c0bbe27 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:42:57 -0800 Subject: [PATCH 02/92] 3D and Materials: Expand as Wireframes expands an id given twice once An object or layer named twice in ids is expanded once, so the selection holds only the edges that are in the document. --- crates/engine/src/cmd/wireframe.rs | 5 +++++ crates/engine/src/cmd/wireframe/tests.rs | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/wireframe.rs b/crates/engine/src/cmd/wireframe.rs index 5e7dd3f0d4..27a35a793b 100644 --- a/crates/engine/src/cmd/wireframe.rs +++ b/crates/engine/src/cmd/wireframe.rs @@ -47,7 +47,12 @@ fn expand_as_wireframes(s: &mut Session, p: &Value) -> Result { // they are. let d = &s.doc()?.doc; let mut wires = vec![]; + let mut seen = std::collections::HashSet::new(); for id in roots { + // An id given twice is expanded once. + if !seen.insert(id) { + continue; + } let Some(n) = d.node(id) else { continue }; if !effects::has_revolve(n) && !effects::has_solid(n) { continue; diff --git a/crates/engine/src/cmd/wireframe/tests.rs b/crates/engine/src/cmd/wireframe/tests.rs index 69e83d4b4a..67e338b781 100644 --- a/crates/engine/src/cmd/wireframe/tests.rs +++ b/crates/engine/src/cmd/wireframe/tests.rs @@ -71,7 +71,8 @@ fn a_layer_with_a_3d_effect_stays_a_layer_holding_the_edges() { let layer = s.doc().unwrap().doc.layers[0].id; s.execute("effect.apply", &json!({"ids":[layer.0],"effect":"threeD.extrude","item":null,"params":{}})).unwrap(); let history = undo_len(&s); - let out = run(&mut s, json!({"ids":[layer.0]})).unwrap(); + // An id given twice is expanded once. + let out = run(&mut s, json!({"ids":[layer.0, layer.0]})).unwrap(); assert_eq!(out["ids"], json!([layer.0])); let st = s.doc().unwrap(); let node = st.doc.node(layer).unwrap(); From c1f7e2eeafd24890fbdb22d01ac0bbf52b2e1fd8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 20:44:32 -0800 Subject: [PATCH 03/92] Stroke: custom arrowheads from an arrowheads file join the arrowhead menus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Stroke panel menu's Edit Arrowheads… (stroke.arrowheads.edit) opens Arrowheads.vectorcraft next to the preferences, creating it with one example head when it is missing. Each symbol in the file is one head, listed by name after the built-in heads in both arrowhead menus: it points right, its registration point is the tip, and its height is drawn as wide as the built-in heads. The file is read at startup and again each time VectorCraft saves it; stroke.arrowheads.reload and stroke.arrowheads.list read and list it. Arrowhead gets a Custom variant, appended after the built-in names, that carries the head's name and outline, so a document that uses one draws, saves, reopens and exports it without the arrowheads file. stroke.set takes {"custom": name} or the {"Custom": {name, path}} form that document.inspect reports. Part of #5421. --- apps/vectorcraft-cli/src/main.rs | 1 + apps/vectorcraft/src/main.rs | 2 + apps/vectorcraft/src/prefs_dir.rs | 8 + crates/doc/src/appearance.rs | 2 +- crates/doc/src/appearance/layers.rs | 6 +- crates/doc/src/appearance/stroke_style.rs | 101 ++++- crates/doc/src/appearance/tests.rs | 47 +- crates/doc/src/lib.rs | 4 +- crates/doc/src/reach.rs | 6 +- crates/effects/src/stroke/arrow.rs | 28 +- crates/effects/src/stroke/tests.rs | 10 +- crates/effects/src/stroke/tests_arrows.rs | 31 +- crates/effects/src/stroke/tests_reach.rs | 17 +- crates/engine/src/cmd.rs | 2 + crates/engine/src/cmd/arrowheads.rs | 423 ++++++++++++++++++ crates/engine/src/cmd/dimension.rs | 2 +- crates/engine/src/cmd/fileio/save.rs | 5 + crates/engine/src/cmd/plugin_call/reviewed.rs | 1 + crates/engine/src/cmd/stroke.rs | 31 +- crates/engine/src/lib.rs | 2 + crates/engine/src/session/documents.rs | 1 + crates/engine/src/tests.rs | 1 + crates/engine/src/tests/arrowheads.rs | 252 +++++++++++ crates/engine/src/tests/panelcmds.rs | 2 +- crates/engine/src/tests/strokegeom.rs | 2 +- crates/pdf/src/tests/stroke.rs | 2 +- crates/render/tests/it/golden.rs | 2 +- crates/render/tests/it/stroke_geometry.rs | 2 +- crates/ui-egui/src/i18n/bg.tsv | 4 + crates/ui-egui/src/i18n/bs.tsv | 4 + crates/ui-egui/src/i18n/ca.tsv | 4 + crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/da.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fi.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hi.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/id.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/ka.tsv | 4 + crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/nl.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/sv.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/vi.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/panels/stroke.rs | 65 ++- .../ui-egui/src/panels/tests_strokedepth.rs | 10 +- docs/mcp.md | 25 ++ docs/ui-parity.md | 1 + 59 files changed, 1143 insertions(+), 59 deletions(-) create mode 100644 crates/engine/src/cmd/arrowheads.rs create mode 100644 crates/engine/src/tests/arrowheads.rs diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 57ab103590..8c4c4fffa2 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -246,6 +246,7 @@ fn with_stores(mut h: Headless) -> Headless { prefs_dir::attach_plugin_store(&mut h.session); prefs_dir::attach_pdf_preset_store(&mut h.session); prefs_dir::attach_custom_dictionary(&mut h.session); + prefs_dir::attach_arrowheads(&mut h.session); h } diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index bb94bad1cc..b3b465b6c8 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -474,6 +474,8 @@ fn main() -> std::process::ExitCode { prefs_dir::attach_pdf_preset_store(&mut app.session); // The custom dictionary's words, kept in their own file next to the preferences. prefs_dir::attach_custom_dictionary(&mut app.session); + // The custom arrowheads, kept in their own file next to the preferences. + prefs_dir::attach_arrowheads(&mut app.session); // Fit the window to its monitor, or put it back where it was (still hidden). let winit_window = cc.winit_window().cloned(); if let Some(w) = &winit_window { diff --git a/apps/vectorcraft/src/prefs_dir.rs b/apps/vectorcraft/src/prefs_dir.rs index 96e09f5e78..b4c0a41103 100644 --- a/apps/vectorcraft/src/prefs_dir.rs +++ b/apps/vectorcraft/src/prefs_dir.rs @@ -81,6 +81,14 @@ pub fn attach_custom_dictionary(session: &mut vectorcraft_engine::Session) { } } +/// Keep the custom arrowheads (Stroke panel menu › Edit Arrowheads…) in the `Arrowheads.vectorcraft` +/// file next to the settings, and list the heads it holds. +pub fn attach_arrowheads(session: &mut vectorcraft_engine::Session) { + if let Some(path) = store_dir(vectorcraft_engine::cmd::arrowheads::FILE_NAME) { + session.attach_arrowheads_file(path.to_string_lossy().to_string()); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 2e19ad44a3..3a37e4ee57 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -10,7 +10,7 @@ mod stroke_style; pub use layers::{Effect, FillLayer, StrokeGradientMode, StrokeLayer, stroke_paint_bounds}; pub use profile::{AngleProfile, PressureProfile, ProfilePreset, SavedProfile, WidthProfile}; -pub use stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign}; +pub use stroke_style::{ArrowAlign, Arrowhead, CustomArrowhead, Dash, LineCap, LineJoin, StrokeAlign}; /// One entry of the appearance stack. // A stroke takes 328 bytes and a fill 104; an appearance holds a few of each. diff --git a/crates/doc/src/appearance/layers.rs b/crates/doc/src/appearance/layers.rs index 923275999b..f8a58758d8 100644 --- a/crates/doc/src/appearance/layers.rs +++ b/crates/doc/src/appearance/layers.rs @@ -193,8 +193,10 @@ impl StrokeLayer { /// How far the start (`end == false`) or end arrowhead can reach from its end point (0 /// without one): the head's diagonal, or with [`ArrowAlign::Extend`] its length, plus the cap. pub fn head_reach(&self, end: bool) -> f64 { - let head = if end { self.end_arrow } else { self.start_arrow }; - head.map_or(0.0, |k| k.reach() * self.arrow_weight(end) + self.width / 2.0) + let head = if end { &self.end_arrow } else { &self.start_arrow }; + // A custom head can paint in front of its tip, which Extend puts past the end point. + let reach = |k: &Arrowhead| if self.arrow_align == ArrowAlign::Extend { k.reach().max(k.length()) } else { k.reach() }; + head.as_ref().map_or(0.0, |k| reach(k) * self.arrow_weight(end) + self.width / 2.0) } /// How far the arrowheads can reach from the path's end points (0 without heads). pub fn arrow_reach(&self) -> f64 { diff --git a/crates/doc/src/appearance/stroke_style.rs b/crates/doc/src/appearance/stroke_style.rs index 46a0c30c33..e029f8bc27 100644 --- a/crates/doc/src/appearance/stroke_style.rs +++ b/crates/doc/src/appearance/stroke_style.rs @@ -1,3 +1,7 @@ +use std::sync::Arc; + +use vectorcraft_geom::{PathData, Rect}; + use super::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -53,8 +57,72 @@ impl Dash { /// (the open arrow's arms are cut square a little behind the box). const HEAD_REACH: f64 = 4.6; +/// An arrowhead the user drew (Stroke panel menu › Edit Arrowheads…), carried with its art so a +/// document that uses it draws it wherever it opens. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(from = "CustomArrowheadData")] +pub struct CustomArrowhead { + /// The name the arrowhead menus show (the symbol's name in the arrowheads file). + pub name: String, + /// The filled outline (non-zero rule) in head units: x from the tip (0) back along the line, y + /// across it, positive to the right of the direction the head points. A head is 4 units wide, + /// as the built-in heads are, and a unit is the head's weight. + pub path: PathData, +} + +#[derive(Deserialize)] +struct CustomArrowheadData { + name: String, + #[serde(default)] + path: PathData, +} + +impl From for CustomArrowhead { + fn from(d: CustomArrowheadData) -> Self { + CustomArrowhead::new(d.name, d.path) + } +} + +impl CustomArrowhead { + /// The most anchors a custom head's outline may have. + pub const MAX_ANCHORS: usize = 4096; + /// How far from the tip a custom head's outline may reach, in head units. + pub const MAX_UNITS: f64 = 64.0; + + /// Head `name` with outline `path` (in head units). An outline with more than + /// [`Self::MAX_ANCHORS`] anchors, a point that is not finite, or a point farther than + /// [`Self::MAX_UNITS`] from the tip is dropped: the head then draws nothing. + pub fn new(name: String, path: PathData) -> Self { + let path = if Self::fits(&path) { path } else { PathData::default() }; + CustomArrowhead { name, path } + } + /// Whether outline `path` is within [`Self::MAX_ANCHORS`] and [`Self::MAX_UNITS`], with finite + /// points only. + pub fn fits(path: &PathData) -> bool { + let anchors = path.subpaths.iter().map(|s| s.anchors.len()).sum::(); + let near = |p: vectorcraft_geom::Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= Self::MAX_UNITS && p.y.abs() <= Self::MAX_UNITS; + anchors <= Self::MAX_ANCHORS && path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| near(a.p) && near(a.h_in) && near(a.h_out)) + } + fn bounds(&self) -> Option { + self.path.control_bounds() + } + /// How far any part of the head can be from its tip, in head units. + pub fn reach(&self) -> f64 { + self.bounds().map_or(0.0, |b| b.x0.hypot(b.y0).max(b.x1.hypot(b.y0)).max(b.x0.hypot(b.y1)).max(b.x1.hypot(b.y1))) + } + /// How far the head spreads either side of its line, in head units. + pub fn half_width(&self) -> f64 { + self.bounds().map_or(0.0, |b| b.y0.abs().max(b.y1.abs())) + } + /// Where the stroke ends under the head, in head units back from the tip: halfway along the + /// head's length. + pub fn stop(&self) -> f64 { + self.bounds().map_or(0.0, |b| b.x1.max(0.0) / 2.0) + } +} + /// An arrowhead shape. Saved by variant name: new shapes are appended, never renamed or reordered. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum Arrowhead { Triangle, TriangleOpen, @@ -102,6 +170,8 @@ pub enum Arrowhead { /// more) meeting at right angles in a sharp tip, cut square at their ends. Twice as wide as /// the other heads. LineArrow, + /// A head the user drew, with its art. + Custom(Arc), } impl Arrowhead { @@ -151,23 +221,43 @@ impl Arrowhead { ]; /// How far any part of the head can be from its tip, in units of its weight. - pub fn reach(self) -> f64 { + pub fn reach(&self) -> f64 { match self { // Its farthest corners are 4 + ½√2 back and 4 − ½√2 out. Arrowhead::LineArrow => 5.75, + Arrowhead::Custom(c) => c.reach(), _ => HEAD_REACH, } } /// How far the head spreads either side of its line, in units of its weight. - pub fn half_width(self) -> f64 { + pub fn half_width(&self) -> f64 { match self { Arrowhead::LineArrow => 4.0, + Arrowhead::Custom(c) => c.half_width(), _ => 2.0, } } + /// How far past its end point a head placed with [`ArrowAlign::Extend`] can paint, in units of + /// its weight: the built-in heads' length, or a custom head's reach past its tip, which sits + /// [`CustomArrowhead::stop`] past the end point. + pub fn length(&self) -> f64 { + match self { + Arrowhead::Custom(c) => c.stop() + c.reach(), + _ => 4.0, + } + } - /// The name the arrowhead menus show. - pub fn label(self) -> &'static str { + /// The name the arrowhead menus show: [`Self::label`], or a custom head's own name. + pub fn name(&self) -> &str { + match self { + Arrowhead::Custom(c) => &c.name, + _ => self.label(), + } + } + + /// The name the arrowhead menus show for a built-in head ("Custom" for a custom one, whose + /// own name is [`Self::name`]). + pub fn label(&self) -> &'static str { match self { Arrowhead::Triangle => "Triangle", Arrowhead::TriangleOpen => "Triangle (open)", @@ -210,6 +300,7 @@ impl Arrowhead { Arrowhead::TriangleBar => "Triangle to Bar", Arrowhead::Oval => "Oval", Arrowhead::LineArrow => "Line Arrow", + Arrowhead::Custom(_) => "Custom", } } } diff --git a/crates/doc/src/appearance/tests.rs b/crates/doc/src/appearance/tests.rs index 0a1b3e1a5d..66f7fa2462 100644 --- a/crates/doc/src/appearance/tests.rs +++ b/crates/doc/src/appearance/tests.rs @@ -264,13 +264,58 @@ fn arrowhead_names_are_stable_and_every_one_is_listed_once() { ]; for name in SAVED { let a: Arrowhead = serde_json::from_value(serde_json::json!(name)).unwrap(); - assert_eq!(serde_json::to_value(a).unwrap(), name); + assert_eq!(serde_json::to_value(&a).unwrap(), name); assert_eq!(Arrowhead::ALL.iter().filter(|x| **x == a).count(), 1, "{name} in the menu once"); } let labels: std::collections::HashSet<&str> = Arrowhead::ALL.iter().map(|a| a.label()).collect(); assert_eq!(labels.len(), Arrowhead::ALL.len(), "labels are distinct"); } +/// A custom head: a triangle from its tip back 3 units, 2 units right and 1 left of its line. +fn custom_head() -> Arrowhead { + use vectorcraft_geom::{PathData, Point, SubPath}; + let path = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(3.0, 2.0), Point::new(3.0, -1.0)], true)); + Arrowhead::Custom(std::sync::Arc::new(CustomArrowhead::new("Wedge".into(), path))) +} + +#[test] +fn custom_arrowheads_carry_their_art_and_measure_it() { + let head = custom_head(); + assert_eq!((head.name(), head.label()), ("Wedge", "Custom")); + assert_eq!(head.reach(), 3.0f64.hypot(2.0)); + assert_eq!(head.half_width(), 2.0); + let Arrowhead::Custom(c) = &head else { panic!("custom") }; + assert_eq!(c.stop(), 1.5, "the line stops halfway along the head"); + // Saved with the stroke, art and all, beside the built-in heads' names. + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0); + (st.start_arrow, st.end_arrow) = (Some(Arrowhead::Triangle), Some(head.clone())); + let json = serde_json::to_value(&st).unwrap(); + assert_eq!(json["start_arrow"], "Triangle"); + assert_eq!(json["end_arrow"]["Custom"]["name"], "Wedge"); + let back: StrokeLayer = serde_json::from_value(json).unwrap(); + assert_eq!(back, st); + // Extended, the tip sits the stop past the end point; on it, only the reach counts. + assert_eq!(head.length(), 1.5 + head.reach()); + assert_eq!(st.head_reach(true), head.length() * 2.0 + 1.0); + st.arrow_align = ArrowAlign::Tip; + assert_eq!(st.head_reach(true), head.reach() * 2.0 + 1.0); +} + +#[test] +fn custom_arrowheads_out_of_bounds_draw_nothing() { + let Arrowhead::Custom(c) = custom_head() else { panic!("custom") }; + let mut path = c.path.clone(); + path.subpaths[0].anchors[1] = vectorcraft_geom::Anchor::corner(vectorcraft_geom::Point::new(1e9, 0.0)); + // A file can hold what `new` drops: reading it drops it too. + let json = serde_json::to_value(CustomArrowhead { name: "Wedge".into(), path }).unwrap(); + let far: CustomArrowhead = serde_json::from_value(json).unwrap(); + assert_eq!((far.name.as_str(), far.path.subpaths.len()), ("Wedge", 0)); + assert_eq!((far.reach(), far.half_width(), far.stop()), (0.0, 0.0, 0.0)); + let anchors = vec![vectorcraft_geom::Anchor::corner(vectorcraft_geom::Point::new(1.0, 0.0)); CustomArrowhead::MAX_ANCHORS + 1]; + let many = CustomArrowhead::new("Many".into(), vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::new(anchors, true))); + assert!(many.path.subpaths.is_empty(), "too many anchors"); +} + #[test] fn arrow_align_defaults_to_extend_and_round_trips() { let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 41a684abde..cdad1e7a93 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -82,8 +82,8 @@ pub(crate) mod skip; pub use appearance::StrokeGradientMode; pub use appearance::{ - AngleProfile, Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile, ProfilePreset, - SavedProfile, StrokeAlign, StrokeLayer, WidthProfile, + AngleProfile, Appearance, AppearanceItem, ArrowAlign, Arrowhead, CustomArrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile, + ProfilePreset, SavedProfile, StrokeAlign, StrokeLayer, WidthProfile, }; pub use assets::ExportAsset; pub use compound_shape::ShapeMode; diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs index f6b9a4d013..763eea56a3 100644 --- a/crates/doc/src/reach.rs +++ b/crates/doc/src/reach.rs @@ -391,13 +391,15 @@ impl StrokeLayer { /// past the end; with [`ArrowAlign::Tip`] its tip is on the end and it lies back along the /// chord the path bends through under it, so anywhere within its length of the end. fn head_contains(&self, at: Point, out: Vec2, end: bool, p: Point, tol: f64) -> bool { - let Some(kind) = (if end { self.end_arrow } else { self.start_arrow }) else { return false }; + let Some(kind) = (if end { &self.end_arrow } else { &self.start_arrow }) else { return false }; let (hw, v) = (self.arrow_weight(end), p - at); match self.arrow_align { ArrowAlign::Tip => v.hypot() <= kind.reach() * hw + tol, ArrowAlign::Extend => { let along = v.dot(out); - v.cross(out).abs() <= kind.half_width() * hw + tol && along <= 4.0 * hw + self.width / 2.0 + tol && along >= -kind.reach() * hw - tol + v.cross(out).abs() <= kind.half_width() * hw + tol + && along <= kind.length() * hw + self.width / 2.0 + tol + && along >= -kind.reach() * hw - tol } } } diff --git a/crates/effects/src/stroke/arrow.rs b/crates/effects/src/stroke/arrow.rs index c96dcb477c..075ab3f7c3 100644 --- a/crates/effects/src/stroke/arrow.rs +++ b/crates/effects/src/stroke/arrow.rs @@ -4,7 +4,8 @@ //! in the back wall of hollow ones (with the cap kept out of the hole) and at the chevron's inner //! corner. With [`ArrowAlign::Tip`] the tip sits on the end point and the stroke is shortened by //! the inset; with [`ArrowAlign::Extend`] the path keeps its length and the tip sits the inset -//! past the end point. Heads added after the first ten are drawn in local units ([`local`]). +//! past the end point. Heads added after the first ten are drawn in local units ([`local`]), as are +//! custom heads, whose stroke ends halfway along their length. use std::borrow::Cow; @@ -37,8 +38,14 @@ pub(super) fn pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a> let last = subs.last().filter(|s| !s.1).map(|s| s.0.clone()); let cap = cap_extent(st.cap, st.width); let (hw0, hw1) = (st.arrow_weight(false), st.arrow_weight(true)); - let start = st.start_arrow.zip(first).map(|(k, r)| (k, r, inset(k, hw0, cap))); - let end = st.end_arrow.zip(last).map(|(k, r)| (k, r, inset(k, hw1, cap))); + let start = st.start_arrow.clone().zip(first).map(|(k, r)| { + let i = inset(&k, hw0, cap); + (k, r, i) + }); + let end = st.end_arrow.clone().zip(last).map(|(k, r)| { + let i = inset(&k, hw1, cap); + (k, r, i) + }); if start.is_none() && end.is_none() { return StrokePieces { line: Cow::Borrowed(bp), heads: vec![] }; } @@ -56,7 +63,8 @@ pub(super) fn pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a> } else { (end_pt + outward * inset, outward) }; - heads.push(Arrow { kind, outline: shape(kind, tip, dir, hw), tip, dir, inset }); + let outline = shape(&kind, tip, dir, hw); + heads.push(Arrow { kind, outline, tip, dir, inset }); }; if let Some((k, r, i)) = start { place(k, r, true, i, hw0); @@ -174,7 +182,7 @@ fn bar(hw: f64) -> f64 { const NOTCH: f64 = 0.7; /// Distance from the tip where the stroke ends, for a stroke whose cap reaches `cap` past its end. -fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 { +fn inset(kind: &Arrowhead, hw: f64, cap: f64) -> f64 { let l = len(hw); // Solid heads: halfway in, far enough that the cap stays behind the tip. let solid = |back: f64| (back / 2.0).max(cap); @@ -187,6 +195,7 @@ fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 { Arrowhead::TriangleOpen | Arrowhead::CircleOpen | Arrowhead::SquareOpen => hollow(l), // The inner corner of the chevron: arm / sin(half angle). Arrowhead::ArrowOpen => (arm(hw) * (l * l + l * l / 4.0).sqrt() / (l / 2.0)).max(cap), + Arrowhead::Custom(c) => (c.stop() * hw).max(cap), _ => match local_stop(kind) { Stop::At(u) => (u * hw).max(cap), Stop::Wall(back) => hollow(back * hw), @@ -195,7 +204,7 @@ fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 { } /// Outline of a `kind` head of weight `hw` with its tip at `tip`, pointing along `dir`. -fn shape(kind: Arrowhead, tip: Point, dir: Vec2, hw: f64) -> BezPath { +fn shape(kind: &Arrowhead, tip: Point, dir: Vec2, hw: f64) -> BezPath { let n = Vec2::new(-dir.y, dir.x); let l = len(hw); let half = l / 2.0; @@ -253,7 +262,7 @@ const HEX: f64 = 2.0 * 1.732_050_807_568_877_2; const LOCAL_TOL: f64 = 1e-3; /// Where the stroke ends under a head drawn in local units ([`local`]), in those units. -fn local_stop(kind: Arrowhead) -> Stop { +fn local_stop(kind: &Arrowhead) -> Stop { match kind { Arrowhead::Target | Arrowhead::TagOpen | Arrowhead::DiamondOpen => Stop::Wall(4.0), Arrowhead::HexagonOpen => Stop::Wall(HEX), @@ -276,8 +285,8 @@ fn local_stop(kind: Arrowhead) -> Stop { /// The outline of a head added after the first ten, in local units: x from the tip (0) back /// along the line (to at most 4; the line arrow's square-cut arms 4.71), y across it (−2 to 2, /// the line arrow −4 to 4; positive to the right of the direction the head points). Every shape -/// is generated here. -fn local(kind: Arrowhead) -> BezPath { +/// is generated here, and a custom head's outline is its own. +fn local(kind: &Arrowhead) -> BezPath { let mut p = BezPath::new(); let poly = |p: &mut BezPath, pts: &[(f64, f64)]| polygon(p, &pts.iter().map(|&q| Point::from(q)).collect::>()); let ring_poly = |p: &mut BezPath, pts: &[(f64, f64)]| ring_polygon(p, &pts.iter().map(|&q| Point::from(q)).collect::>(), ring(1.0)); @@ -373,6 +382,7 @@ fn local(kind: Arrowhead) -> BezPath { let t = FRAC_1_SQRT_2; poly(&mut p, &[(0.0, 0.0), (4.0, 4.0), (4.0 + t, 4.0 - t), (2.0 * t, 0.0), (4.0 + t, t - 4.0), (4.0, -4.0)]); } + Arrowhead::Custom(c) => p = c.path.to_bezpath(), // The first ten are drawn by `shape`. Arrowhead::Triangle | Arrowhead::TriangleOpen diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs index 46969ff639..aa4ea8dc9f 100644 --- a/crates/effects/src/stroke/tests.rs +++ b/crates/effects/src/stroke/tests.rs @@ -282,7 +282,7 @@ fn no_heads_borrows_the_path_and_closed_paths_get_none() { #[test] fn tip_mode_shortens_the_stroke_by_the_head_and_puts_the_tip_on_the_end_point() { for kind in Arrowhead::ALL { - let st = arrows(kind, ArrowAlign::Tip, LineCap::Butt); + let st = arrows(kind.clone(), ArrowAlign::Tip, LineCap::Butt); let l = line(0.0, 100.0); let p = stroke_pieces(&l, &st); let [h] = &p.heads[..] else { panic!("one head") }; @@ -337,7 +337,7 @@ fn extend_mode_keeps_the_path_and_overhangs_by_the_inset() { #[test] fn hollow_heads_are_rings_and_the_stroke_stays_out_of_the_hole() { for (kind, cap) in [(Arrowhead::CircleOpen, LineCap::Round), (Arrowhead::SquareOpen, LineCap::Square), (Arrowhead::TriangleOpen, LineCap::Butt)] { - let st = arrows(kind, ArrowAlign::Tip, cap); + let st = arrows(kind.clone(), ArrowAlign::Tip, cap); let l = line(0.0, 100.0); let p = stroke_pieces(&l, &st); let h = &p.heads[0]; @@ -363,7 +363,7 @@ fn hollow_heads_are_rings_and_the_stroke_stays_out_of_the_hole() { fn round_caps_never_poke_past_the_tip() { for kind in Arrowhead::ALL { for scale in [10.0, 100.0, 300.0] { - let mut st = arrows(kind, ArrowAlign::Tip, LineCap::Round); + let mut st = arrows(kind.clone(), ArrowAlign::Tip, LineCap::Round); st.arrow_scale = (scale, scale); let l = line(0.0, 100.0); let p = stroke_pieces(&l, &st); @@ -405,8 +405,8 @@ fn degenerate_paths_and_patterns_neither_panic_nor_hang() { for pat in patterns { for off in [0.0, -7.0, 1e12, f64::INFINITY] { let st = stroke(2.0, |s| { - s.start_arrow = Some(kind); - s.end_arrow = Some(kind); + s.start_arrow = Some(kind.clone()); + s.end_arrow = Some(kind.clone()); s.arrow_align = align; s.cap = cap; s.dash = Some(dashed(pat, off)); diff --git a/crates/effects/src/stroke/tests_arrows.rs b/crates/effects/src/stroke/tests_arrows.rs index 4328048991..a32a70245b 100644 --- a/crates/effects/src/stroke/tests_arrows.rs +++ b/crates/effects/src/stroke/tests_arrows.rs @@ -50,7 +50,7 @@ fn every_outline_is_drawn_inside_its_box_without_self_intersections() { let (o, d) = (Point::ORIGIN, Point::new(2.0, 2.0)); assert!(cross((o, d), (Point::new(0.0, 2.0), Point::new(2.0, 0.0))) && !cross((o, d), (d, Point::new(4.0, 0.0))), "the check itself"); for kind in Arrowhead::ALL { - let h = head(kind); + let h = head(kind.clone()); assert!(h.outline.area().abs() > 4.0, "{kind:?} is drawn"); let b = h.outline.bounding_box(); let (reach, side) = (2.0 * kind.reach(), 2.0 * kind.half_width()); @@ -73,7 +73,7 @@ fn every_outline_is_drawn_inside_its_box_without_self_intersections() { #[test] fn the_stroke_ends_inside_every_head() { for kind in Arrowhead::ALL { - let h = head(kind); + let h = head(kind.clone()); // Just in front of where the line stops, on its centre line. let end = h.tip - h.dir * (h.inset - 0.1); assert_ne!(h.outline.winding(end), 0, "{kind:?}: the line ends outside the head (inset {})", h.inset); @@ -112,3 +112,30 @@ fn half_arrows_barb_on_their_own_side() { assert!(head(Arrowhead::HalfArrowLeft).outline.bounding_box().y1 < 1.5); assert!(head(Arrowhead::HalfArrowRight).outline.bounding_box().y1 > 3.0); } + +#[test] +fn custom_heads_draw_their_own_art_at_the_tip() { + use vectorcraft_doc::CustomArrowhead; + use vectorcraft_geom::{PathData, SubPath}; + // From the tip back 3 units, 2 to the right of the line and 1 to the left. + let wedge = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(3.0, 2.0), Point::new(3.0, -1.0)], true)); + let kind = Arrowhead::Custom(std::sync::Arc::new(CustomArrowhead::new("Wedge".into(), wedge))); + let h = head(kind.clone()); + // Weight 2: drawn back to x 94, right of +x is +y on a y-down page. + let b = h.outline.bounding_box(); + assert!((b.x0 - 94.0).abs() < 1e-9 && (b.x1 - 100.0).abs() < 1e-9 && (b.y0 + 2.0).abs() < 1e-9 && (b.y1 - 4.0).abs() < 1e-9, "{b:?}"); + assert_eq!(h.outline.winding(Point::new(95.0, 1.0)), h.outline.winding(Point::new(95.0, 0.5))); + assert_ne!(h.outline.winding(Point::new(95.0, 1.0)), 0); + // The line stops halfway along the head. + assert!((h.inset - 3.0).abs() < 1e-9, "inset {}", h.inset); + // Extended, the tip sits the inset past the end point, and scaling scales the art. + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0); + (st.start_arrow, st.arrow_scale) = (Some(kind), (200.0, 100.0)); + let mut l = BezPath::new(); + l.move_to((0.0, 0.0)); + l.line_to((100.0, 0.0)); + let p = stroke_pieces(&l, &st); + let [s] = &p.heads[..] else { panic!("one head") }; + assert!((s.tip.x + 6.0).abs() < 1e-9 && s.dir.x < 0.0, "tip {:?}", s.tip); + assert!((s.outline.bounding_box().width() - 12.0).abs() < 1e-9); +} diff --git a/crates/effects/src/stroke/tests_reach.rs b/crates/effects/src/stroke/tests_reach.rs index 29a48343ed..6e141fd5b5 100644 --- a/crates/effects/src/stroke/tests_reach.rs +++ b/crates/effects/src/stroke/tests_reach.rs @@ -99,14 +99,27 @@ fn bounds_and_clicks_cover_what_every_stroke_paints() { } } +/// Custom heads: one wholly behind its tip, and a long one reaching as far in front of its tip as +/// behind it (its tip sits halfway along it past the end point with Extend). +fn custom_heads() -> Vec { + use vectorcraft_geom::{Point as P, SubPath}; + let head = |name: &str, pts: &[(f64, f64)]| { + let pts: Vec

= pts.iter().map(|&(x, y)| P::new(x, y)).collect(); + let path = PathData::single(SubPath::polyline(&pts, true)); + Arrowhead::Custom(std::sync::Arc::new(vectorcraft_doc::CustomArrowhead::new(name.into(), path))) + }; + vec![head("Wedge", &[(0.0, 0.0), (3.0, 2.0), (3.0, -1.0)]), head("Spindle", &[(-10.0, 0.0), (0.0, 2.0), (10.0, 0.0), (0.0, -2.0)])] +} + #[test] fn bounds_and_clicks_cover_every_arrowhead() { let bp = &paths()[0]; - for kind in Arrowhead::ALL { + for kind in Arrowhead::ALL.into_iter().chain(custom_heads()) { for align in [ArrowAlign::Extend, ArrowAlign::Tip] { for (scale, cap) in [(50.0, LineCap::Round), (100.0, LineCap::Butt), (300.0, LineCap::Square)] { let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 3.0); - (st.start_arrow, st.end_arrow, st.arrow_align, st.arrow_scale, st.cap) = (Some(kind), Some(kind), align, (scale, scale), cap); + (st.start_arrow, st.end_arrow, st.arrow_align, st.arrow_scale, st.cap) = + (Some(kind.clone()), Some(kind.clone()), align, (scale, scale), cap); check(bp, &st, &format!("{kind:?} {align:?} {scale}%")); } } diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 72e33b8993..12e39d1c3c 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -4,6 +4,7 @@ pub mod actionset; mod addons; mod apiref; pub(crate) mod appearance; +pub mod arrowheads; mod assets; mod attributes; pub mod brushlib; @@ -297,6 +298,7 @@ pub fn command_specs() -> &'static [CommandSpec] { // Split out of `paint` (M3.8); kept next to it so lists in registry order (the command // palette, `engine.commands`) show them where they always were. v.extend(stroke::specs()); + v.extend(arrowheads::specs()); v.extend(appearance::specs()); v.extend(style::specs()); v.extend(swatch::specs()); diff --git a/crates/engine/src/cmd/arrowheads.rs b/crates/engine/src/cmd/arrowheads.rs new file mode 100644 index 0000000000..8e2eee865f --- /dev/null +++ b/crates/engine/src/cmd/arrowheads.rs @@ -0,0 +1,423 @@ +//! Custom arrowheads (Stroke panel menu › Edit Arrowheads…). The arrowheads file is a native +//! document kept next to the preferences ([`Session::attach_arrowheads_file`]). Each of its symbols +//! is one arrowhead, listed by the symbol's name after the built-in heads in the Stroke panel's +//! arrowhead menus. A head points right (+x): the symbol's registration point is its tip, and the +//! art's height (its extent across the line) is drawn as wide as the built-in heads, four times +//! the head's weight. A head covers what its art paints: the visible fills, and the visible +//! strokes outlined, of the paths, compound paths and groups in it, and its gradient meshes, with +//! their geometry effects applied and clip groups clipped. A head is one solid shape in the stroke's paint: art at 0 % +//! opacity is left out, and other opacities, blend modes and opacity masks do not change the +//! shape. Saving the file reloads the heads. A stroke that uses a custom head carries a copy of its art +//! ([`vectorcraft_doc::CustomArrowhead`]), so its document draws it anywhere. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, CustomArrowhead, Document, Node, NodeId, NodeKind, Symbol, SymbolRegistration}; +use vectorcraft_geom::{Affine, FillRule, PathData, Point, SubPath}; +use vectorcraft_pathops::BoolOp; + +use super::{CommandSpec, Result, Session, always, bad, cmd}; + +/// The arrowheads file's name, in the folder of the preferences. +pub const FILE_NAME: &str = "Arrowheads.vectorcraft"; +/// The largest arrowheads file read. +pub const MAX_FILE_BYTES: u64 = 64 << 20; +/// The most heads read from the file; symbols past it are skipped. +pub const MAX_HEADS: usize = 256; +/// The most anchors a symbol's art, and the shapes it paints, may have; a symbol past it is skipped. +pub const MAX_ART_ANCHORS: usize = 16 * CustomArrowhead::MAX_ANCHORS; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "stroke.arrowheads.list", + "Arrowheads", + [], + None, + "{} → {arrowheads: [{name (stroke.set `startArrow`/`endArrow` as {custom: name}), reach, halfWidth (in head weights)}], file: the arrowheads file's path or null (no file here, as on the web), skipped: [{name, reason}] symbols of the file that are not heads, error: why the file could not be read, or null} the custom arrowheads, listed after the built-in ones in the Stroke panel's arrowhead menus", + always, + list + ), + cmd!( + "stroke.arrowheads.edit", + "Edit Arrowheads", + [], + None, + "{} open the arrowheads file (a native document next to the preferences; created first with one example head when it is missing). Each symbol in it is one custom arrowhead named after the symbol: it points right, its registration point is the tip, and its height is drawn as wide as the built-in heads; its visible fills and outlined strokes make the head. Saving the file reloads the heads → {path, created: whether the file was made now}", + has_file, + edit + ), + cmd!( + "stroke.arrowheads.reload", + "Reload Arrowheads", + [], + None, + "{} read the arrowheads file again (a save of it from VectorCraft does this already) → {count, skipped: [{name, reason}]}", + has_file, + reload + ), + ] +} + +/// The custom arrowheads read from the arrowheads file. +#[derive(Default)] +pub struct Arrowheads { + path: Option, + heads: Vec>, + skipped: Vec<(String, String)>, + error: Option, +} + +impl Arrowheads { + /// The arrowheads file's path; `None` without one (the web, runs without preferences). + pub fn path(&self) -> Option<&str> { + self.path.as_deref() + } + /// The heads, in the order of the file's symbols. + pub fn heads(&self) -> &[Arc] { + &self.heads + } + /// Head `name`. + pub fn get(&self, name: &str) -> Option> { + self.heads.iter().find(|h| h.name == name).cloned() + } + fn set(&mut self, read: Read) { + (self.heads, self.skipped) = (read.heads.into_iter().map(Arc::new).collect(), read.skipped); + self.error = None; + } +} + +/// The heads a document holds, and the symbols that are not heads with the reason. +#[derive(Debug, Default)] +pub struct Read { + pub heads: Vec, + pub skipped: Vec<(String, String)>, +} + +/// The heads of document `doc`: one per symbol, up to [`MAX_HEADS`] (see the module docs). +pub fn heads_of(doc: &Document) -> Read { + let mut d = doc.clone(); + let brushes = vectorcraft_brush::library(&d); + let mut out = Read::default(); + for (i, sym) in doc.symbols.iter().enumerate() { + if i >= MAX_HEADS { + out.skipped.push((sym.name.clone(), format!("only the first {MAX_HEADS} symbols are read"))); + continue; + } + if out.heads.iter().any(|h| h.name == sym.name) { + out.skipped.push((sym.name.clone(), "another symbol has this name".into())); + continue; + } + match head(&mut d, &brushes, sym) { + Ok(h) => out.heads.push(h), + Err(why) => out.skipped.push((sym.name.clone(), why.into())), + } + } + out +} + +/// Symbol `sym` of `d` as a head. +fn head(d: &mut Document, brushes: &[vectorcraft_brush::Brush], sym: &Symbol) -> std::result::Result { + const TOO_DETAILED: &str = "its art is too long or too detailed for a head"; + if art_anchors(&sym.art) > MAX_ART_ANCHORS { + return Err(TOO_DETAILED); + } + let art = baked_art(d, sym); + let mut shapes = Shapes::default(); + painted(d, brushes, &art, &mut shapes); + if shapes.too_detailed { + return Err(TOO_DETAILED); + } + let area = shapes.united(); + let b = area.bounds().filter(|b| b.height() > 1e-9 && b.is_finite()).ok_or("its art paints nothing")?; + let tip = b.center() + d.symbol_registration(&sym.name).offset_from_center(b.width(), b.height()); + // Head units: x back from the tip, y across to the right of +x, 4 units to the art's height. + let k = 4.0 / b.height(); + let local = Affine::new([-k, 0.0, 0.0, k, k * tip.x, -k * tip.y]); + let h = CustomArrowhead::new(sym.name.clone(), area.transformed(local)); + if h.path.subpaths.is_empty() { + return Err(TOO_DETAILED); + } + Ok(h) +} + +/// The anchors of the paths in `n` and of the pieces of its gradient meshes ([`mesh_anchors`]), its +/// members' included. +fn art_anchors(n: &Node) -> usize { + let own = match &n.kind { + NodeKind::Mesh(m) => mesh_anchors(m), + _ => n.path_data().map_or(0, PathData::anchor_count), + }; + n.children().map_or(own, |c| c.iter().fold(own, |sum, c| sum.saturating_add(art_anchors(c)))) +} + +/// The art of symbol `sym` of `d` with every geometry effect in it applied (its objects', fills', +/// strokes' and groups'), as the canvas draws it: the art baked as for export, on a copy of `d`. +fn baked_art(d: &Document, sym: &Symbol) -> Node { + // A plain new layer, so the file's own layers' effects stay out of the art. + let mut tmp = d.clone(); + tmp.layers = Document::new(1.0, 1.0).layers; + let Some(children) = tmp.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) else { return (*sym.art).clone() }; + *children = vec![sym.art.clone()]; + let baked = vectorcraft_render::effects::bake_document(&tmp); + baked.as_ref().and_then(|b| b.layers.first()?.children()?.first().map(|c| (**c).clone())).unwrap_or_else(|| (*sym.art).clone()) +} + +/// The shapes a symbol's art paints, up to [`MAX_ART_ANCHORS`] anchors in all. +#[derive(Default)] +struct Shapes { + list: Vec<(PathData, FillRule)>, + anchors: usize, + /// More anchors were asked for than fit; the shapes past the limit were not made. + too_detailed: bool, +} + +impl Shapes { + /// Whether `anchors` more fit; when they do not, the shapes are too detailed. + fn room(&mut self, anchors: usize) -> bool { + self.too_detailed |= self.anchors.saturating_add(anchors) > MAX_ART_ANCHORS; + !self.too_detailed + } + fn push(&mut self, shape: (PathData, FillRule)) { + if self.room(shape.0.anchor_count()) { + self.anchors += shape.0.anchor_count(); + self.list.push(shape); + } + } + fn united(&self) -> PathData { + let refs: Vec<(&PathData, FillRule)> = self.list.iter().map(|(p, r)| (p, *r)).collect(); + vectorcraft_pathops::unite_all(&refs) + } + /// The shapes of `inside` clipped to `outline` (filled under `rule`), added as one. + fn push_clipped(&mut self, inside: Shapes, outline: &PathData, rule: FillRule) { + if inside.too_detailed || !self.room(inside.anchors) { + self.too_detailed = true; + return; + } + let clipped = vectorcraft_pathops::boolean(&inside.united(), FillRule::NonZero, outline, rule, BoolOp::Intersect); + if !clipped.subpaths.is_empty() { + self.push((clipped, FillRule::NonZero)); + } + } +} + +/// The shapes the shown paths and compound paths in `n` (baked art) paint: their filled areas and +/// their strokes' outlines, its groups' members included (baking has already made a group's own +/// fills and strokes art among its members), and the areas its gradient meshes cover. A clip +/// group's clipped art is clipped to +/// its clipping path, whose own fill and stroke paint unclipped, as on the canvas. Hidden or fully +/// transparent art is left out. +fn painted(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &Node, out: &mut Shapes) { + if !n.visible || n.opacity <= 0.0 || out.too_detailed { + return; + } + match &n.kind { + NodeKind::Path { guide: true, .. } => {} + NodeKind::Path { .. } | NodeKind::Compound { .. } => { + fills(n, out); + // The outline holds a copy of the path for each fill too, which adds nothing new. + if let Some(o) = super::pathops::outline_strokes(d, brushes, n) { + fills_in(&o, out); + } + } + NodeKind::Group { children, clip: true } => { + let Some((clip, rest)) = children.split_first() else { return }; + painted(d, brushes, clip, out); + // Nothing to clip by hides the clipped art, as on the canvas. + let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(clip) else { return }; + let mut inside = Shapes { anchors: out.anchors, ..Default::default() }; + rest.iter().for_each(|c| painted(d, brushes, c, &mut inside)); + inside.anchors -= out.anchors; + out.push_clipped(inside, &PathData::from_bezpath(&outline), rule); + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| painted(d, brushes, c, out)), + NodeKind::Mesh(_) => mesh_area(n, out), + _ => {} + } +} + +/// The areas the shown paths and gradient meshes in `n` (an outlined stroke) fill, clipped by its +/// clip groups, whose clipping paths' own fills paint unclipped. A stroke with a gradient along or +/// across it is outlined as gradient meshes clipped to the stroke's outline. +fn fills_in(n: &Node, out: &mut Shapes) { + if !n.visible || n.opacity <= 0.0 || out.too_detailed { + return; + } + match &n.kind { + NodeKind::Mesh(_) => mesh_area(n, out), + NodeKind::Group { children, clip: true } => { + let Some((clip, rest)) = children.split_first() else { return }; + fills_in(clip, out); + let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(clip) else { return }; + let mut inside = Shapes { anchors: out.anchors, ..Default::default() }; + rest.iter().for_each(|c| fills_in(c, &mut inside)); + inside.anchors -= out.anchors; + out.push_clipped(inside, &PathData::from_bezpath(&outline), rule); + } + _ => match n.children() { + Some(children) => children.iter().for_each(|c| fills_in(c, out)), + None => fills(n, out), + }, + } +} + +/// The anchors [`mesh_area`] makes at most of mesh `m`: its outline's, one a patch along each side, +/// when no point of it is fully transparent, else four by four quads of four anchors a patch. +fn mesh_anchors(m: &vectorcraft_doc::GradientMesh) -> usize { + let (rows, cols) = (m.rows as usize, m.cols as usize); + if opaque(m) { rows.saturating_add(cols).saturating_mul(2) } else { rows.saturating_mul(cols).saturating_mul(64) } +} + +fn opaque(m: &vectorcraft_doc::GradientMesh) -> bool { + m.points.iter().all(|p| p.opacity > 0.0) +} + +/// The area gradient mesh `n` paints: its outline when no point of it is fully transparent, else +/// the pieces of it that are not fully transparent (four by four quads a patch). A mesh whose +/// pieces do not fit in `out` makes the shapes too detailed. +fn mesh_area(n: &Node, out: &mut Shapes) { + let NodeKind::Mesh(m) = &n.kind else { return }; + if !out.room(mesh_anchors(m)) { + return; + } + if opaque(m) { + let outline = m.outline(); + if !outline.subpaths.is_empty() { + out.push((outline, FillRule::NonZero)); + } + return; + } + for q in m.quads(4) { + if q.opacity > 0.0 || q.corners.iter().any(|(_, o)| *o > 0.0) { + out.push((PathData::single(SubPath::polyline(&q.pts, true)), FillRule::NonZero)); + } + } +} + +/// The area path `n` fills, when a shown fill paints it. +fn fills(n: &Node, out: &mut Shapes) { + let paints = |i: &AppearanceItem| matches!(i, AppearanceItem::Fill(f) if f.visible && f.opacity > 0.0 && !f.paint.is_none()); + if n.appearance.items.iter().any(paints) + && let Some(shape) = super::pathops::node_path(n) + { + out.push(shape); + } +} + +/// The document a missing arrowheads file starts as: one example head, a solid triangle. +pub fn starter() -> Document { + let mut doc = Document::new(612.0, 792.0); + doc.title = "Arrowheads".into(); + let name = "Example Arrowhead"; + let tri = SubPath::polyline(&[Point::new(-10.0, -8.0), Point::new(10.0, 0.0), Point::new(-10.0, 8.0)], true); + let art = Node::path(NodeId(0), PathData::single(tri), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 1.0)); + let art = doc.reid(&art); + doc.symbols.push(Symbol { name: name.into(), art: Arc::new(art), dynamic: false }); + // The tip is the right edge's middle. + doc.set_symbol_registration(name, SymbolRegistration(5)); + let id = doc.alloc_id(); + let instance = Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::translate((306.0, 396.0)) }); + let layer = doc.layers.first().map(|l| l.id); + let _ = doc.insert(layer, 0, instance); + doc +} + +impl Session { + /// Keep the custom arrowheads in the file at `path` and read the heads it holds (none while it + /// is missing; an unreadable file is reported by `stroke.arrowheads.list`). + pub fn attach_arrowheads_file(&mut self, path: String) { + self.arrowheads.path = Some(path); + self.reload_arrowheads(); + } + + /// Read the arrowheads file again. + fn reload_arrowheads(&mut self) { + let Some(path) = self.arrowheads.path.clone() else { return }; + match read(&path) { + Ok(Some(doc)) => self.arrowheads.set(heads_of(&doc)), + Ok(None) => self.arrowheads.set(Read::default()), + Err(e) => { + self.arrowheads.set(Read::default()); + self.arrowheads.error = Some(e); + } + } + } + + /// Document `doc` was saved to `path`: when that is the arrowheads file, its heads are the + /// ones listed now. + pub(crate) fn arrowheads_saved(&mut self, path: &str, doc: &Document) { + if self.arrowheads.path.as_deref().is_some_and(|a| same_file(a, path)) { + self.arrowheads.set(heads_of(doc)); + } + } +} + +fn same_file(a: &str, b: &str) -> bool { + let canon = |p: &str| std::fs::canonicalize(p).unwrap_or_else(|_| std::path::PathBuf::from(p)); + std::path::Path::new(a) == std::path::Path::new(b) || canon(a) == canon(b) +} + +/// The document in the arrowheads file at `path`; `None` when there is no file. +fn read(path: &str) -> std::result::Result, String> { + let size = match std::fs::metadata(path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(format!("{path}: {e}")), + Ok(m) if !m.is_file() => return Err(format!("{path}: not a file")), + Ok(m) => m.len(), + }; + if size > MAX_FILE_BYTES { + return Err(format!("{path}: larger than {} MB", MAX_FILE_BYTES >> 20)); + } + let bytes = crate::file_access::unconfined(|| super::fileio::read_file(path)).map_err(|e| e.to_string())?; + let parse = || super::fileio::native_file(&bytes).map(|(doc, ..)| doc).map_err(|e| e.to_string()); + crate::guard::catch_panic(parse).map_err(|_| format!("{path}: could not be read"))?.map(Some) +} + +fn has_file(s: &Session) -> std::result::Result<(), String> { + if s.arrowheads.path.is_some() { Ok(()) } else { Err("no arrowheads file here".into()) } +} + +fn skipped(s: &Session) -> Vec { + s.arrowheads.skipped.iter().map(|(name, reason)| json!({"name": name, "reason": reason})).collect() +} + +fn list(s: &mut Session, _: &Value) -> Result { + let heads: Vec = s.arrowheads.heads.iter().map(|h| json!({"name": h.name, "reach": h.reach(), "halfWidth": h.half_width()})).collect(); + Ok(json!({"arrowheads": heads, "file": s.arrowheads.path, "skipped": skipped(s), "error": s.arrowheads.error})) +} + +fn reload(s: &mut Session, _: &Value) -> Result { + const C: &str = "stroke.arrowheads.reload"; + s.reload_arrowheads(); + if let Some(e) = &s.arrowheads.error { + return Err(bad(C, e.clone())); + } + Ok(json!({"count": s.arrowheads.heads.len(), "skipped": skipped(s)})) +} + +fn edit(s: &mut Session, _: &Value) -> Result { + const C: &str = "stroke.arrowheads.edit"; + let path = s.arrowheads.path.clone().ok_or_else(|| bad(C, "no arrowheads file here"))?; + let created = !std::path::Path::new(&path).exists(); + if created { + if let Some(dir) = std::path::Path::new(&path).parent() { + std::fs::create_dir_all(dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?; + } + let bytes = vectorcraft_format::save_file(&starter()); + crate::file_access::unconfined(|| super::fileio::write_file(&path, &bytes)).map_err(|e| bad(C, e.to_string()))?; + s.reload_arrowheads(); + } + // Already open: that document comes to the front. + let open = s.documents().iter().position(|st| st.path.as_deref().is_some_and(|p| same_file(p, &path))); + match open { + Some(i) => { + s.set_active(i); + } + None => { + crate::file_access::unconfined(|| s.execute("document.open", &json!({ "path": path })))?; + } + } + Ok(json!({"path": path, "created": created})) +} diff --git a/crates/engine/src/cmd/dimension.rs b/crates/engine/src/cmd/dimension.rs index e1b88d5d9f..44ac08d7ad 100644 --- a/crates/engine/src/cmd/dimension.rs +++ b/crates/engine/src/cmd/dimension.rs @@ -568,7 +568,7 @@ fn appearance(o: &DimensionOptions, mode: ColorMode, ext: bool, arrows: bool) -> ArrowStyle::Tick => Some(Arrowhead::Slash), ArrowStyle::None => None, }; - st.start_arrow = head; + st.start_arrow = head.clone(); st.end_arrow = head; st.arrow_scale = (o.arrow_scale, o.arrow_scale); st.arrow_align = vectorcraft_doc::appearance::ArrowAlign::Tip; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 9f4e5b0572..6e814569a0 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -1005,6 +1005,8 @@ impl SaveJob { } // The other open documents that place this one show the new version. let written = self.plan.path.clone().filter(|_| matches!(self.plan.format.id, "vectorcraft" | "template")); + // A save of the arrowheads file lists its heads in the arrowhead menus. + let native = (self.plan.format.id == "vectorcraft").then(|| self.snapshot.clone()); if let Some(st) = s.document_mut(uid) { self.finish(st); } @@ -1012,6 +1014,9 @@ impl SaveJob { crate::cmd::recovery::forget(s, uid); } if let Some(path) = written { + if let Some(doc) = native { + s.arrowheads_saved(&path, &doc); + } crate::cmd::links::refresh_placed(s, uid, &path); } } diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 541389bb89..ce48ccd933 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -675,5 +675,6 @@ pub(super) const DENIED: &[&str] = &[ "object.blend.options", "object.liquify", "object.pathfinder.options", + "stroke.arrowheads.edit", "type.compositeFonts.set", ]; diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index e97659bfc9..49b3128f61 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -4,8 +4,10 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ - ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, Scaling, StrokeAlign, StrokeLayer, Unit, WidthProfile, + ArrowAlign, Arrowhead, CharStyle, CustomArrowhead, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, Scaling, StrokeAlign, StrokeLayer, + Unit, WidthProfile, }; +use vectorcraft_geom::PathData; use super::appearance::{ItemTarget, edit_items, item_target}; use super::paint::painted; @@ -20,7 +22,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", + "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", has_doc, stroke_set ), @@ -222,6 +224,23 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { None => Ok(None), Some(Value::Null) => Ok(Some(None)), Some(Value::String(n)) if n == "none" => Ok(Some(None)), + // A custom head by name, from the arrowheads file (stroke.arrowheads.list). + Some(Value::Object(o)) if o.len() == 1 && o.contains_key("custom") => { + let Some(Value::String(name)) = o.get("custom") else { return Err(bad(C, format!("{k}: custom must be a name"))) }; + let head = s.arrowheads.get(name).ok_or_else(|| bad(C, format!("no custom arrowhead {name} (see stroke.arrowheads.list)")))?; + Ok(Some(Some(Arrowhead::Custom(head)))) + } + // A custom head with its art: an outline the head would drop is refused, not drawn empty. + Some(Value::Object(o)) if o.contains_key("Custom") => { + let raw = o.get("Custom").and_then(|c| c.get("path")).cloned().unwrap_or(Value::Null); + let path: PathData = serde_json::from_value(raw).map_err(|e| bad(C, format!("{k}: bad custom arrowhead path: {e}")))?; + if path.subpaths.iter().all(|sp| sp.anchors.is_empty()) || !CustomArrowhead::fits(&path) { + let (most, far) = (CustomArrowhead::MAX_ANCHORS, CustomArrowhead::MAX_UNITS); + return Err(bad(C, format!("{k}: a custom arrowhead's path needs 1 to {most} anchors, each within {far} head units of its tip"))); + } + let head = p.get(k).cloned().unwrap_or(Value::Null); + serde_json::from_value::(head).map(|a| Some(Some(a))).map_err(|e| bad(C, format!("{k}: bad custom arrowhead: {e}"))) + } Some(v) => serde_json::from_value::(v.clone()).map(|a| Some(Some(a))).map_err(|_| bad(C, format!("unknown arrowhead {v}"))), } }; @@ -238,11 +257,11 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { }; let set = |st: &mut StrokeLayer| { change.apply(st); - if let Some(a) = sa { - st.start_arrow = a; + if let Some(a) = &sa { + st.start_arrow = a.clone(); } - if let Some(a) = ea { - st.end_arrow = a; + if let Some(a) = &ea { + st.end_arrow = a.clone(); } if let Some(a) = arrow_align { st.arrow_align = a; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 091348a6bd..9a1a536d84 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -151,6 +151,8 @@ pub struct Session { pub symbol_libraries: cmd::symbollib::Libraries, /// User Defined and loaded brush libraries (Window → Brush Libraries); not saved. pub brush_libraries: cmd::brushlib::Libraries, + /// The custom arrowheads of the arrowheads file (`stroke.arrowheads.*`); none without one. + pub arrowheads: cmd::arrowheads::Arrowheads, /// The Libraries panel's libraries of graphics, colours and text styles (`library.*`). pub libraries: cmd::library::Libraries, /// Where the saved PDF presets are kept, one file each; `None`: only for the session diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 63fbcd5a78..a0ca0e3084 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -80,6 +80,7 @@ impl Session { style_libraries: Default::default(), symbol_libraries: Default::default(), brush_libraries: Default::default(), + arrowheads: Default::default(), libraries: Default::default(), pdf_preset_dir: None, dictionary_file: None, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ca0501c8f7..db0a8521a3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -9,6 +9,7 @@ pub(super) mod adjust; mod ai_open_path; mod appearance; mod areafit; +mod arrowheads; mod assets; mod assets_3d; mod attributes; diff --git a/crates/engine/src/tests/arrowheads.rs b/crates/engine/src/tests/arrowheads.rs new file mode 100644 index 0000000000..1e5c1fe5a8 --- /dev/null +++ b/crates/engine/src/tests/arrowheads.rs @@ -0,0 +1,252 @@ +//! Custom arrowheads (`cmd/arrowheads.rs`): heads read from the arrowheads file's symbols, listed, +//! set with `stroke.set`, reloaded when the file is saved, and kept with the documents that use +//! them. + +use std::sync::Arc; + +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Arrowhead, Document, Node, StrokeLayer, Symbol, SymbolRegistration}; +use vectorcraft_geom::{PathData, Point, SubPath}; + +use crate::cmd::arrowheads::{self, heads_of}; +use crate::*; + +fn polygon(pts: &[(f64, f64)], ap: Appearance) -> Node { + let pts: Vec = pts.iter().map(|&(x, y)| Point::new(x, y)).collect(); + Node::path(NodeId(0), PathData::single(SubPath::polyline(&pts, true)), ap) +} + +fn add_symbol(d: &mut Document, name: &str, art: Node) { + let art = d.reid(&art); + d.symbols.push(Symbol { name: name.into(), art: Arc::new(art), dynamic: false }); +} + +fn filled() -> Appearance { + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 1.0) +} + +/// A folder of its own for test `name`, emptied. +fn folder(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("vectorcraft-arrowheads-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn session_with_line() -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let r = s.execute("shape.line", &json!({"x1": 10, "y1": 100, "x2": 150, "y2": 100})).unwrap(); + (s, NodeId(r["id"].as_u64().unwrap())) +} + +fn stroke(s: &Session, id: NodeId) -> StrokeLayer { + s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().clone() +} + +#[test] +fn each_symbol_is_a_head_pointing_right_with_its_tip_at_the_registration_point() { + let mut d = Document::new(100.0, 100.0); + // 20 high, so a unit is 5 pt: tip on the right edge's middle. + add_symbol(&mut d, "Wedge", polygon(&[(0.0, -10.0), (20.0, 0.0), (0.0, 10.0)], filled())); + d.set_symbol_registration("Wedge", SymbolRegistration(5)); + // A stroke alone paints its outline: a 10 × 4 band, tip at its centre (the default). + let mut line = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0); + line.stroke_mut().unwrap().cap = vectorcraft_doc::LineCap::Butt; + let band = Node::path(NodeId(0), PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0)], false)), line); + add_symbol(&mut d, "Band", band); + // Neither filled nor stroked: no head. + add_symbol(&mut d, "Ghost", polygon(&[(0.0, 0.0), (5.0, 0.0), (5.0, 5.0)], Appearance::default())); + // Far longer than wide. + add_symbol(&mut d, "Needle", polygon(&[(0.0, 0.0), (1000.0, 0.5), (0.0, 1.0)], filled())); + // A fill at 0 % opacity paints nothing. + let mut clear = filled(); + if let Some(vectorcraft_doc::AppearanceItem::Fill(f)) = clear.items.first_mut() { + f.opacity = 0.0; + } + add_symbol(&mut d, "Clear", polygon(&[(0.0, 0.0), (5.0, 0.0), (5.0, 5.0)], clear)); + // More anchors than a symbol's art may have: skipped before its shapes are united. + let dense: Vec<(f64, f64)> = (0..=arrowheads::MAX_ART_ANCHORS).map(|i| ((i as f64).cos(), (i as f64).sin())).collect(); + add_symbol(&mut d, "Dense", polygon(&dense, filled())); + // A 20 × 4 bar clipped to a 4 × 4 square: a 4 × 4 head. + let mut mask = polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut mask.kind { + *clipping = true; + } + let bar = polygon(&[(0.0, 0.0), (20.0, 0.0), (20.0, 4.0), (0.0, 4.0)], filled()); + add_symbol(&mut d, "Clipped", Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(bar)], clip: true })); + // An 8 × 4 bar grown by 2 pt on every side (Offset Path): 12 × 8, so 6 units long. + let mut grown = filled(); + grown.effects.push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: json!({"offset": 2}), visible: true }); + add_symbol(&mut d, "Grown", polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], grown)); + // The same effect on the fill alone. + let mut fill_grown = filled(); + fill_grown.items[0].effects_mut().push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: json!({"offset": 2}), visible: true }); + add_symbol(&mut d, "Fill Grown", polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], fill_grown)); + // And on a group holding the bar. + let mut group = Node::group(NodeId(0), vec![Arc::new(polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], filled()))]); + group.appearance.effects.push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: json!({"offset": 2}), visible: true }); + add_symbol(&mut d, "Group Grown", group); + // A clipping path stroked 2 pt: its stroke paints unclipped and does not widen the clip. + let mut frame = polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0)); + if let NodeKind::Path { clipping, .. } = &mut frame.kind { + *clipping = true; + } + let bar = polygon(&[(0.0, 0.0), (20.0, 0.0), (20.0, 4.0), (0.0, 4.0)], filled()); + add_symbol(&mut d, "Framed", Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(frame), Arc::new(bar)], clip: true })); + // A group's own fill paints its unpainted 8 × 4 member. + let mut painted_group = Node::group(NodeId(0), vec![Arc::new(polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], Appearance::default()))]); + painted_group.appearance = filled().into(); + add_symbol(&mut d, "Group Fill", painted_group); + // A 10 × 4 band stroked with a gradient along it. + let gradient = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()))); + let mut along = Appearance::basic(Paint::None, gradient, 4.0); + if let Some(st) = along.stroke_mut() { + (st.cap, st.gradient_mode) = (vectorcraft_doc::LineCap::Butt, vectorcraft_doc::StrokeGradientMode::Along); + } + let band = Node::path(NodeId(0), PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0)], false)), along); + add_symbol(&mut d, "Gradient Band", band); + // An 8 × 4 mesh of two patches whose right one is fully transparent: a 4 × 4 head. + let corners = vectorcraft_doc::live::grid_points(vectorcraft_geom::Rect::new(0.0, 0.0, 8.0, 4.0), 1, 2); + let points = corners + .iter() + .map(|&p| vectorcraft_doc::MeshPoint { p, color: Color::BLACK, opacity: if p.x > 1.0 { 0.0 } else { 1.0 }, handles: Default::default() }) + .collect(); + let mesh = vectorcraft_doc::GradientMesh { rows: 1, cols: 2, points }; + add_symbol(&mut d, "Half Mesh", Node::new(NodeId(0), NodeKind::Mesh(mesh))); + // An effect on the file's layer is not part of any symbol. + Arc::make_mut(&mut d.layers[0]).appearance.effects.push(vectorcraft_doc::Effect { + id: "path.offsetPath".into(), + params: json!({"offset": 5}), + visible: true, + }); + let read = heads_of(&d); + let names: Vec<&str> = read.heads.iter().map(|h| h.name.as_str()).collect(); + assert_eq!(names, ["Wedge", "Band", "Clipped", "Grown", "Fill Grown", "Group Grown", "Framed", "Group Fill", "Gradient Band", "Half Mesh"]); + let b = read.heads[9].path.bounds().unwrap(); + assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "the mesh's painted patch: {b:?}"); + let b = read.heads[7].path.bounds().unwrap(); + assert!((b.width() - 8.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "the group's fill: {b:?}"); + let (g, plain) = (read.heads[8].path.bounds().unwrap(), read.heads[1].path.bounds().unwrap()); + let near = |a: f64, b: f64| (a - b).abs() < 1e-3; + assert!(near(g.x0, plain.x0) && near(g.x1, plain.x1) && near(g.y0, plain.y0) && near(g.y1, plain.y1), "the gradient stroke's outline: {g:?}"); + let framed = read.heads[6].path.to_bezpath(); + let b = vectorcraft_geom::Shape::bounding_box(&framed); + assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "6 × 6 with the stroke: {b:?}"); + assert_ne!(vectorcraft_geom::Shape::winding(&framed, Point::new(0.1, 0.1)), 0, "the clipped bar fills the middle"); + for h in &read.heads[3..6] { + let b = h.path.bounds().unwrap(); + assert!((b.width() - 6.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "{}: the effect's outline: {b:?}", h.name); + } + let skipped: Vec<&str> = read.skipped.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!(skipped, ["Ghost", "Needle", "Clear", "Dense"], "{:?}", read.skipped); + let b = read.heads[2].path.bounds().unwrap(); + assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "clipped to the square: {b:?}"); + let b = read.heads[0].path.bounds().unwrap(); + assert!(b.x0.abs() < 1e-6 && (b.x1 - 4.0).abs() < 1e-6 && (b.y0 + 2.0).abs() < 1e-6 && (b.y1 - 2.0).abs() < 1e-6, "{b:?}"); + assert!((read.heads[0].reach() - 4.0f64.hypot(2.0)).abs() < 1e-6); + let b = read.heads[1].path.bounds().unwrap(); + assert!((b.x0 + 5.0).abs() < 1e-3 && (b.x1 - 5.0).abs() < 1e-3 && (b.y0 + 2.0).abs() < 1e-3 && (b.y1 - 2.0).abs() < 1e-3, "{b:?}"); +} + +#[test] +fn the_arrowheads_file_is_made_opened_and_reloaded_when_saved() { + let dir = folder("edit"); + let file = dir.join(arrowheads::FILE_NAME).to_string_lossy().to_string(); + let (mut s, id) = session_with_line(); + s.attach_arrowheads_file(file.clone()); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + assert_eq!((list["arrowheads"].as_array().unwrap().len(), list["file"].as_str()), (0, Some(file.as_str()))); + assert!(s.execute("stroke.set", &json!({"endArrow": {"custom": "Example Arrowhead"}})).is_err(), "no such head yet"); + let line_doc = s.doc().unwrap().uid; + let r = s.execute("stroke.arrowheads.edit", &json!({})).unwrap(); + assert_eq!(r["created"], true); + assert_eq!(s.doc().unwrap().path.as_deref(), Some(file.as_str()), "the file is the active document"); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + assert_eq!(list["arrowheads"][0]["name"], "Example Arrowhead"); + // A second head drawn in the file is listed once the file is saved. + let st = s.doc_mut().unwrap(); + let d = Arc::make_mut(&mut st.doc); + add_symbol(d, "Square", polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], filled())); + assert_eq!(s.execute("stroke.arrowheads.list", &json!({})).unwrap()["arrowheads"].as_array().unwrap().len(), 1); + s.execute("document.save", &json!({})).unwrap(); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + let names: Vec<&str> = list["arrowheads"].as_array().unwrap().iter().filter_map(|h| h["name"].as_str()).collect(); + assert_eq!(names, ["Example Arrowhead", "Square"]); + // Editing again from the line's document brings the open file to the front, without making + // it anew or opening it twice. + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert_eq!(s.doc().unwrap().uid, line_doc); + assert_eq!(s.execute("stroke.arrowheads.edit", &json!({})).unwrap()["created"], false); + assert_eq!((s.documents().len(), s.doc().unwrap().path.as_deref()), (2, Some(file.as_str()))); + // The line takes the head with its art. + s.execute("document.activate", &json!({"index": 0})).unwrap(); + s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": {"custom": "Square"}})).unwrap(); + let Some(Arrowhead::Custom(c)) = stroke(&s, id).end_arrow else { panic!("a custom head") }; + assert_eq!(c.name, "Square"); + assert!(s.execute("stroke.set", &json!({"endArrow": {"custom": 3}})).is_err()); + assert!(s.execute("stroke.set", &json!({"endArrow": {"custom": "Circle"}})).is_err()); + // A head given with its art is refused when the art could not be drawn. + let mut far = serde_json::to_value(Arrowhead::Custom(c.clone())).unwrap(); + far["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(1e6, 1.0)], true))).unwrap(); + assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": far})).is_err()); + let mut empty = serde_json::to_value(Arrowhead::Custom(c)).unwrap(); + empty["Custom"]["path"] = json!({"subpaths": []}); + assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": empty})).is_err()); + assert!(matches!(stroke(&s, id).end_arrow, Some(Arrowhead::Custom(h)) if h.name == "Square"), "the head stays as it was"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_document_keeps_its_custom_heads_without_the_arrowheads_file() { + let (mut s, id) = session_with_line(); + let mut d = Document::new(100.0, 100.0); + add_symbol(&mut d, "Wedge", polygon(&[(0.0, -10.0), (20.0, 0.0), (0.0, 10.0)], filled())); + let head = Arrowhead::Custom(Arc::new(heads_of(&d).heads.remove(0))); + // As document.inspect reports it, so it reads back unchanged. + s.execute("stroke.set", &json!({"startArrow": head, "endArrow": "Triangle"})).unwrap(); + assert_eq!(stroke(&s, id).start_arrow, Some(head.clone())); + let inspect = s.execute("document.inspect", &json!({})).unwrap().to_string(); + assert!(inspect.contains("\"Wedge\""), "inspect names the head"); + let ink = |s: &Session| { + vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &Default::default()) + }; + let with_head = ink(&s); + // Saved and opened in a session that has no arrowheads file: the head is drawn the same. + let saved = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone(); + let mut o = Session::new(); + o.execute("document.open", &json!({"name": "a.vectorcraft", "dataBase64": saved})).unwrap(); + assert_eq!(stroke(&o, id).start_arrow, Some(head)); + assert_eq!(ink(&o).pixels, with_head.pixels); + // SVG and PDF write it as they write the built-in heads. + let svg = |s: &mut Session| s.execute("document.export", &json!({"format": "svg"})).unwrap()["dataBase64"].as_str().unwrap().len(); + let headed = svg(&mut o); + o.execute("stroke.set", &json!({"ids": [id.0], "startArrow": null})).unwrap(); + assert!(headed > svg(&mut o), "the head is in the SVG"); + // Undo puts it back. + o.execute("edit.undo", &json!({})).unwrap(); + assert!(matches!(stroke(&o, id).start_arrow, Some(Arrowhead::Custom(_)))); +} + +#[test] +fn an_unreadable_arrowheads_file_lists_no_heads_and_says_why() { + let dir = folder("bad"); + let file = dir.join(arrowheads::FILE_NAME); + std::fs::write(&file, b"not a drawing").unwrap(); + let mut s = Session::new(); + s.attach_arrowheads_file(file.to_string_lossy().to_string()); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + assert!(list["arrowheads"].as_array().unwrap().is_empty()); + assert!(list["error"].is_string(), "{list}"); + assert!(s.execute("stroke.arrowheads.reload", &json!({})).is_err()); + // A folder in its place is not read either. + std::fs::remove_file(&file).unwrap(); + std::fs::create_dir(&file).unwrap(); + assert!(s.execute("stroke.arrowheads.reload", &json!({})).is_err()); + let _ = std::fs::remove_dir_all(&dir); + // Without a file (the web, runs without preferences) there is nothing to edit. + let mut bare = Session::new(); + assert!(bare.execute("stroke.arrowheads.edit", &json!({})).is_err()); + assert_eq!(bare.execute("stroke.arrowheads.list", &json!({})).unwrap()["file"], serde_json::Value::Null); +} diff --git a/crates/engine/src/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs index 7e7204f6e1..79b5f724a4 100644 --- a/crates/engine/src/tests/panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -2109,7 +2109,7 @@ fn empty_artboard_checks_match_the_visual_bounds_on_varied_strokes() { st.cap = [LineCap::Butt, LineCap::Round, LineCap::Square][next(3) as usize]; st.align = [StrokeAlign::Center, StrokeAlign::Inside, StrokeAlign::Outside][next(3) as usize]; st.miter_limit = 1.0 + next(20) as f64; - let mut head = || (next(3) == 0).then(|| Arrowhead::ALL[next(Arrowhead::ALL.len() as u64) as usize]); + let mut head = || (next(3) == 0).then(|| Arrowhead::ALL[next(Arrowhead::ALL.len() as u64) as usize].clone()); (st.start_arrow, st.end_arrow) = (head(), head()); st.arrow_scale = (50.0 + next(250) as f64, 50.0 + next(250) as f64); if next(3) == 0 { diff --git a/crates/engine/src/tests/strokegeom.rs b/crates/engine/src/tests/strokegeom.rs index f7500af358..524dabef3e 100644 --- a/crates/engine/src/tests/strokegeom.rs +++ b/crates/engine/src/tests/strokegeom.rs @@ -51,7 +51,7 @@ fn profiles_come_from_the_preset_catalogue() { assert!(spec.params.contains("arrowAlign")); // …and every arrowhead name `startArrow`/`endArrow` accept. for a in vectorcraft_doc::Arrowhead::ALL { - let name = serde_json::to_value(a).unwrap(); + let name = serde_json::to_value(&a).unwrap(); assert!(spec.params.contains(name.as_str().unwrap()), "{name}"); s.execute("stroke.set", &json!({"endArrow": name})).unwrap(); assert_eq!(stroke(&s, id).end_arrow, Some(a)); diff --git a/crates/pdf/src/tests/stroke.rs b/crates/pdf/src/tests/stroke.rs index 91436a093c..d57cbc42d0 100644 --- a/crates/pdf/src/tests/stroke.rs +++ b/crates/pdf/src/tests/stroke.rs @@ -52,7 +52,7 @@ pub(crate) fn painted_boxes(d: &Document) -> (Vec, Vec) { fn arrowheads_and_the_trimmed_line_match_the_shared_geometry() { for align in [ArrowAlign::Extend, ArrowAlign::Tip] { for kind in Arrowhead::ALL { - let (d, n) = line_doc(arrow_stroke(kind, align, 1.0)); + let (d, n) = line_doc(arrow_stroke(kind.clone(), align, 1.0)); let bp = n.path_data().unwrap().to_bezpath(); let st = n.appearance.stroke().unwrap(); let pieces = stroke_pieces(&bp, st); diff --git a/crates/render/tests/it/golden.rs b/crates/render/tests/it/golden.rs index 4820cf8b60..d6962e0e67 100644 --- a/crates/render/tests/it/golden.rs +++ b/crates/render/tests/it/golden.rs @@ -300,7 +300,7 @@ fn arrowheads_add_ink_at_the_ends() { // Every arrowhead kind renders. for a in Arrowhead::ALL { let mut b = DocBuilder::new(100.0, 100.0); - b.path(line(), stroke_only(2.0, |s| s.end_arrow = Some(a)), |_| {}); + b.path(line(), stroke_only(2.0, |s| s.end_arrow = Some(a.clone())), |_| {}); assert!(render(&b.build()).ink() > plain.ink(), "{a:?}"); } } diff --git a/crates/render/tests/it/stroke_geometry.rs b/crates/render/tests/it/stroke_geometry.rs index a8813da23a..2580fc79c8 100644 --- a/crates/render/tests/it/stroke_geometry.rs +++ b/crates/render/tests/it/stroke_geometry.rs @@ -114,7 +114,7 @@ fn a_hollow_circle_head_is_empty_inside() { fn heads_are_drawn_where_the_shared_geometry_puts_them_and_never_past_the_tip() { for align in [ArrowAlign::Extend, ArrowAlign::Tip] { for kind in Arrowhead::ALL { - let d = arrow_doc(kind, align, 1.0, |s| s.cap = LineCap::Round); + let d = arrow_doc(kind.clone(), align, 1.0, |s| s.cap = LineCap::Round); let h = head_of(&d); if align == ArrowAlign::Tip { assert_eq!(h.tip, Point::new(80.0, 50.0)); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d83b5ac3c6..73d8e836c9 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2759,6 +2759,10 @@ digits Malayalam Малаялам Search Preferences Търсене в настройките No settings found Няма намерени настройки Reset Profiles Нулиране на профилите + Arrowheads Върхове на стрелки + Edit Arrowheads Редактиране на върхове на стрелки + Edit Arrowheads… Редактиране на върхове на стрелки… + Reload Arrowheads Презареждане на върхове на стрелки Reset Rotate View Нулиране на изгледа на завъртане Rotate View to Selection Завъртане на изгледа спрямо селекцията Reset the rows to the artwork's colors Нулиране на редовете към цветовете на илюстрацията diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 663e95ceaa..22faf858cd 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -3819,6 +3819,10 @@ theme Appearance Izgled Add to Profiles Dodaj u profile Delete Profile Izbriši profil Reset Profiles Vrati profile na početno + Arrowheads Vrhovi strelica + Edit Arrowheads Uredi vrhove strelica + Edit Arrowheads… Uredi vrhove strelica… + Reload Arrowheads Ponovo učitaj vrhove strelica Stroke Options Opcije poteza Width Profiles Profili širine Apply Graphic Style Primijeni grafički stil diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9c60e87e00..4ad2acfe23 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3507,6 +3507,10 @@ digits Malayalam Malaiàlam No settings found No s'ha trobat cap paràmetre Reset Printing and Proofing Restableix impressió i prova Reset Profiles Restableix els perfils + Arrowheads Puntes de fletxa + Edit Arrowheads Edita les puntes de fletxa + Edit Arrowheads… Edita les puntes de fletxa… + Reload Arrowheads Torna a carregar les puntes de fletxa Reset Rotate View Restableix la rotació de la vista Reset Ruler Origin Restableix l’origen de les regles Reset the rows to the artwork's colors Restableix les files als colors de l’obra diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e8f9666fae..75a65e2b2b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3870,6 +3870,10 @@ theme Appearance Motiv Add to Profiles Přidat do profilů Delete Profile Odstranit profil Reset Profiles Obnovit profily + Arrowheads Šipky + Edit Arrowheads Upravit šipky + Edit Arrowheads… Upravit šipky… + Reload Arrowheads Znovu načíst šipky Stroke Options Možnosti tahu Width Profiles Profily šířky Apply Graphic Style Použít grafický styl diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 31915ed6bf..171447e2f3 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3594,6 +3594,10 @@ theme Appearance Udseende Add to Profiles Føj til profiler Delete Profile Slet profil Reset Profiles Nulstil profiler + Arrowheads Pilespidser + Edit Arrowheads Redigér pilespidser + Edit Arrowheads… Redigér pilespidser… + Reload Arrowheads Genindlæs pilespidser Stroke Options Konturindstillinger Width Profiles Breddeprofiler Apply Graphic Style Anvend grafikstil diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2c5424d81f..b926704b9c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2791,6 +2791,10 @@ digits Malayalam Malayalam Search Preferences Voreinstellungen durchsuchen No settings found Keine Einstellungen gefunden Reset Profiles Profile zurücksetzen + Arrowheads Pfeilspitzen + Edit Arrowheads Pfeilspitzen bearbeiten + Edit Arrowheads… Pfeilspitzen bearbeiten… + Reload Arrowheads Pfeilspitzen neu laden Rotate View Ansicht drehen Reset Rotate View Ansichtsdrehung zurücksetzen Rotate View to Selection Ansicht nach Auswahl drehen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6071ed5be4..d9048c58b2 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3423,6 +3423,10 @@ digits Malayalam Μαλαγιάλαμ Reset OpenType Features Επαναφορά χαρακτηριστικών OpenType Reset Panel Επαναφορά πίνακα Reset Profiles Επαναφορά προφίλ + Arrowheads Αιχμές βελών + Edit Arrowheads Επεξεργασία αιχμών βελών + Edit Arrowheads… Επεξεργασία αιχμών βελών… + Reload Arrowheads Επαναφόρτωση αιχμών βελών Reset Rotate View Επαναφορά περιστροφής προβολής Rotate View to Selection Περιστροφή προβολής ως προς την επιλογή Reset the rows to the artwork's colors Επαναφορά γραμμών στα χρώματα του σχεδίου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4a74e3ae39..d246f64bab 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2833,6 +2833,10 @@ digits Malayalam Malayálam Search Preferences Buscar en preferencias No settings found No se encontraron ajustes Reset Profiles Restablecer perfiles + Arrowheads Puntas de flecha + Edit Arrowheads Editar puntas de flecha + Edit Arrowheads… Editar puntas de flecha… + Reload Arrowheads Volver a cargar puntas de flecha Reset Rotate View Restablecer rotación de vista Rotate View to Selection Rotar vista según la selección Reset the rows to the artwork's colors Restablecer las filas a los colores de la ilustración diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f2ec64f602..ec528e38d7 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3794,6 +3794,10 @@ theme Appearance Ulkoasu Add to Profiles Lisää profiileihin Delete Profile Poista profiili Reset Profiles Palauta profiilit + Arrowheads Nuolenkärjet + Edit Arrowheads Muokkaa nuolenkärkiä + Edit Arrowheads… Muokkaa nuolenkärkiä… + Reload Arrowheads Lataa nuolenkärjet uudelleen Stroke Options Viivan asetukset Width Profiles Leveysprofiilit Apply Graphic Style Käytä grafiikkatyyliä diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 807b3363b4..e82137bdde 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2825,6 +2825,10 @@ digits Malayalam Malayalam Search Preferences Rechercher dans les préférences No settings found Aucun réglage trouvé Reset Profiles Réinitialiser les profils + Arrowheads Pointes de flèche + Edit Arrowheads Modifier les pointes de flèche + Edit Arrowheads… Modifier les pointes de flèche… + Reload Arrowheads Recharger les pointes de flèche Reset Rotate View Réinitialiser la rotation de l’affichage Rotate View to Selection Faire pivoter l’affichage selon la sélection Reset the rows to the artwork's colors Réinitialiser les lignes avec les couleurs de l’illustration diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 572a19779f..12fad0afa3 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3811,6 +3811,10 @@ antiAlias Sharp शार्प No settings found कोई सेटिंग नहीं मिली Reset Printing and Proofing मुद्रण और प्रूफिंग रीसेट करें Reset Profiles प्रोफ़ाइल रीसेट करें + Arrowheads एरोहेड्स + Edit Arrowheads एरोहेड्स संपादित करें + Edit Arrowheads… एरोहेड्स संपादित करें… + Reload Arrowheads एरोहेड्स फिर से लोड करें Reset Rotate View व्यू घुमाएँ रीसेट करें Reset Ruler Origin शासकीय उत्पत्ति रीसेट करें Reset Symbol Instance सिंबल इंस्टेंस रीसेट करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8c2d2339bc..8bd9b3730c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3377,6 +3377,10 @@ mark-style Japanese Japán No settings found Nincs ilyen beállítás Reset Printing and Proofing Printing and Proofing visszaállítása Reset Profiles Profilok visszaállítása + Arrowheads Nyílhegyek + Edit Arrowheads Nyílhegyek szerkesztése + Edit Arrowheads… Nyílhegyek szerkesztése… + Reload Arrowheads Nyílhegyek újratöltése Reset Rotate View Nézetforgatás visszaállítása Rotate View to Selection Nézet elforgatása a kijelöléshez Reset the rows to the artwork's colors Sorok visszaállítása a grafika színeire diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 606ffae97c..5665633eb8 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3594,6 +3594,10 @@ theme Appearance Tampilan Add to Profiles Tambahkan ke profil Delete Profile Hapus profil Reset Profiles Atur ulang profil + Arrowheads Mata panah + Edit Arrowheads Edit mata panah + Edit Arrowheads… Edit mata panah… + Reload Arrowheads Muat ulang mata panah Stroke Options Opsi goresan Width Profiles Profil lebar Apply Graphic Style Terapkan gaya grafis diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index bf4c9e7b58..e881a370fa 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2867,6 +2867,10 @@ digits Malayalam Malayalam Search Preferences Cerca nelle preferenze No settings found Nessuna impostazione trovata Reset Profiles Reimposta profili + Arrowheads Punte di freccia + Edit Arrowheads Modifica punte di freccia + Edit Arrowheads… Modifica punte di freccia… + Reload Arrowheads Ricarica punte di freccia Rotate View Ruota vista Reset Rotate View Reimposta rotazione vista Rotate View to Selection Ruota vista in base alla selezione diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7a12afcadb..84ca0e4650 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2864,6 +2864,10 @@ mark-style Japanese 日本式 Search Preferences 環境設定を検索 No settings found 設定が見つかりません Reset Profiles プロファイルをリセット + Arrowheads 矢印 + Edit Arrowheads 矢印を編集 + Edit Arrowheads… 矢印を編集… + Reload Arrowheads 矢印を再読み込み Reset the rows to the artwork's colors 行をアートワークのカラーにリセット Reset to Default 初期設定に戻す Reset to White, Black 白、黒にリセット diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c58480c426..17bea31b37 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3433,6 +3433,10 @@ theme Appearance იერსახე Add to Profiles პროფილებში დამატება Delete Profile პროფილის წაშლა Reset Profiles პროფილების ჩამოყრა + Arrowheads ისრის წვერები + Edit Arrowheads ისრის წვერების რედაქტირება + Edit Arrowheads… ისრის წვერების რედაქტირება… + Reload Arrowheads ისრის წვერების ხელახლა ჩატვირთვა Stroke Options კონტურის პარამეტრები Width Profiles სიგანის პროფილები Apply Graphic Style გრაფიკული სტილის გამოყენება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 39efdecf2e..990684f2bc 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2853,6 +2853,10 @@ mark-style Japanese 일본식 Search Preferences 환경 설정 검색 No settings found 설정을 찾을 수 없음 Reset Profiles 프로필 재설정 + Arrowheads 화살촉 + Edit Arrowheads 화살촉 편집 + Edit Arrowheads… 화살촉 편집… + Reload Arrowheads 화살촉 다시 불러오기 Reset the rows to the artwork's colors 행을 아트웍 색상으로 재설정 Reset to Default 기본값으로 재설정 Reset to White, Black 흰색, 검정으로 재설정 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index dd1d22edc0..15d0f9024a 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3608,6 +3608,10 @@ theme Appearance Vormgeving Add to Profiles Aan profielen toevoegen Delete Profile Profiel verwijderen Reset Profiles Profielen opnieuw instellen + Arrowheads Pijlpunten + Edit Arrowheads Pijlpunten bewerken + Edit Arrowheads… Pijlpunten bewerken… + Reload Arrowheads Pijlpunten opnieuw laden Stroke Options Lijnopties Width Profiles Breedteprofielen Apply Graphic Style Grafische stijl toepassen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index dd850a3a4a..f21124fd30 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3733,6 +3733,10 @@ theme Appearance Wygląd Add to Profiles Dodaj do profili Delete Profile Usuń profil Reset Profiles Resetuj profile + Arrowheads Groty strzałek + Edit Arrowheads Edytuj groty strzałek + Edit Arrowheads… Edytuj groty strzałek… + Reload Arrowheads Wczytaj ponownie groty strzałek Stroke Options Opcje obrysu Width Profiles Profile szerokości Apply Graphic Style Zastosuj styl graficzny diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f67ae6cda2..5b57e5962e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2370,6 +2370,10 @@ digits Malayalam Malaiala Search Preferences Pesquisar nas Preferências No settings found Nenhuma configuração encontrada Reset Profiles Redefinir Perfis + Arrowheads Pontas de Seta + Edit Arrowheads Editar Pontas de Seta + Edit Arrowheads… Editar Pontas de Seta… + Reload Arrowheads Recarregar Pontas de Seta Rotate View Girar Visualização Reset Rotate View Redefinir Visualização Girada Rotate View to Selection Girar Visualização para a Seleção diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8eb5425823..e6611285f7 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2862,6 +2862,10 @@ digits Malayalam Малаялам Search Preferences Поиск в настройках No settings found Настройки не найдены Reset Profiles Сбросить профили + Arrowheads Стрелки + Edit Arrowheads Редактировать стрелки + Edit Arrowheads… Редактировать стрелки… + Reload Arrowheads Перезагрузить стрелки Rotate View Повернуть вид Reset Rotate View Сбросить поворот вида Rotate View to Selection Повернуть вид по выделению diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fb2c463de0..5a310e2829 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2769,6 +2769,10 @@ digits Malayalam Malayalam Search Preferences Sök i inställningar No settings found Inga inställningar hittades Reset Profiles Återställ profiler + Arrowheads Pilspetsar + Edit Arrowheads Redigera pilspetsar + Edit Arrowheads… Redigera pilspetsar… + Reload Arrowheads Läs in pilspetsar igen Reset Rotate View Återställ Rotera vy Rotate View to Selection Rotera vyn efter markeringen Reset the rows to the artwork's colors Återställ raderna till teckningens färger diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d45a171cc6..a10b459531 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2638,6 +2638,10 @@ digits Malayalam Malayalam Search Preferences Tercihlerde Ara No settings found Ayar bulunamadı Reset Profiles Profilleri Sıfırla + Arrowheads Ok Uçları + Edit Arrowheads Ok Uçlarını Düzenle + Edit Arrowheads… Ok Uçlarını Düzenle… + Reload Arrowheads Ok Uçlarını Yeniden Yükle Reset Rotate View Döndürme Görünümünü Sıfırla Rotate View to Selection Görünümü Seçime Döndür Reset the rows to the artwork's colors Satırları resmin renklerine göre sıfırlayın diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index acaf20b6b9..b420758cfa 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2859,6 +2859,10 @@ digits Malayalam Малаялам Search Preferences Пошук у налаштуваннях No settings found Налаштування не знайдено Reset Profiles Скинути профілі + Arrowheads Стрілки + Edit Arrowheads Редагувати стрілки + Edit Arrowheads… Редагувати стрілки… + Reload Arrowheads Перезавантажити стрілки Rotate View Повернути вид Reset Rotate View Скинути поворот виду Rotate View to Selection Повернути вид за виділенням diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4a2bbd5a62..db91921932 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3140,6 +3140,10 @@ mark-style Japanese Kiểu Nhật Search Preferences Tìm trong tùy chọn No settings found Không tìm thấy cài đặt Reset Profiles Đặt lại hồ sơ màu + Arrowheads Đầu mũi tên + Edit Arrowheads Chỉnh sửa đầu mũi tên + Edit Arrowheads… Chỉnh sửa đầu mũi tên… + Reload Arrowheads Tải lại đầu mũi tên Rotate View Xoay chế độ xem Reset Rotate View Đặt lại xoay chế độ xem Rotate View to Selection Xoay chế độ xem theo vùng chọn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0eb7c7ebe5..56b4b7bcd6 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2733,6 +2733,10 @@ digits Malayalam 马拉雅拉姆数字 Search Preferences 搜索首选项 No settings found 未找到设置 Reset Profiles 重置配置文件 + Arrowheads 箭头 + Edit Arrowheads 编辑箭头 + Edit Arrowheads… 编辑箭头… + Reload Arrowheads 重新载入箭头 Reset Rotate View 重置旋转视图 Rotate View to Selection 按所选对象旋转视图 Reset the rows to the artwork's colors 将各行重置为图稿的颜色 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 084a7e7708..35b2f69cc4 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3811,6 +3811,10 @@ digits Malayalam 馬來亞拉姆數字 Search Preferences 搜尋偏好設定 No settings found 找不到設定 Reset Profiles 重設描述檔 + Arrowheads 箭頭 + Edit Arrowheads 編輯箭頭 + Edit Arrowheads… 編輯箭頭… + Reload Arrowheads 重新載入箭頭 Rotate View 旋轉檢視 Reset Rotate View 重設旋轉檢視 Rotate View to Selection 依選取範圍旋轉檢視 diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index f6ece0ac55..7be5b65e7c 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -100,10 +100,37 @@ pub(crate) fn edited_dash_pattern(fields: &[Option; 6], previous: &[f64]) - out } -fn arrow_label(a: Option) -> &'static str { +fn arrow_label(a: Option<&Arrowhead>) -> &'static str { a.map_or("None", Arrowhead::label) } +/// The name an arrowhead menu shows for `a`: a built-in head's in the interface language, a +/// custom head's own. +fn arrow_name(a: Option<&Arrowhead>) -> String { + match a { + Some(Arrowhead::Custom(c)) => c.name.clone(), + _ => tl!(arrow_label(a)).to_string(), + } +} + +/// The preview cache key of `a`: its name, and for a custom head its art. +fn arrow_key(a: Option<&Arrowhead>) -> String { + match a { + Some(Arrowhead::Custom(c)) => { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + for s in &c.path.subpaths { + (s.anchors.len(), s.closed).hash(&mut h); + for p in s.anchors.iter().flat_map(|a| [a.p, a.h_in, a.h_out]) { + (p.x.to_bits(), p.y.to_bits()).hash(&mut h); + } + } + format!("custom:{}:{:x}", c.name, h.finish()) + } + _ => format!("{a:?}"), + } +} + /// Apply Stroke panel options (`stroke.set` params): while the Type tool edits text, to the /// selected characters' stroke. fn set(app: &mut VectorcraftApp, p: Value) { @@ -272,14 +299,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); widgets::divider(ui); // Arrowheads. - let (sa, ea) = (st.as_ref().and_then(|s| s.start_arrow), st.as_ref().and_then(|s| s.end_arrow)); + let (sa, ea) = (st.as_ref().and_then(|s| s.start_arrow.clone()), st.as_ref().and_then(|s| s.end_arrow.clone())); + // The custom heads of the arrowheads file follow the built-in ones. + let custom: Vec = app.session.arrowheads.heads().iter().cloned().map(Arrowhead::Custom).collect(); ui.horizontal(|ui| { row_label(ui, tl!("Arrowheads:")); - if let Some(a) = arrow_dropdown(ui, "arrow-start", sa, true) { - set(app, json!({"startArrow": a.map(|a| format!("{a:?}"))})); + // A custom head goes with its art, as document.inspect reports it. + if let Some(a) = arrow_dropdown(ui, "arrow-start", sa.as_ref(), &custom, true) { + set(app, json!({ "startArrow": a })); } - if let Some(a) = arrow_dropdown(ui, "arrow-end", ea, false) { - set(app, json!({"endArrow": a.map(|a| format!("{a:?}"))})); + if let Some(a) = arrow_dropdown(ui, "arrow-end", ea.as_ref(), &custom, false) { + set(app, json!({ "endArrow": a })); } if widgets::icon_button_enabled(ui, "arrow-left-right", tl!("Swap start and end arrowheads"), false, st.is_some(), 22.0).clicked() { app.run("stroke.setAdvanced", json!({"swapArrows": true})).ok(); @@ -360,14 +390,15 @@ pub(super) fn arrow_doc(a: Option, start: bool, w: f64, h: f64) -> Op /// Draw an arrowhead preview (the head as the canvas draws it) in `r`: a line with the head at the /// left (`start`) or right end. -fn paint_arrow(ui: &Ui, r: Rect, a: Option, start: bool, color: Color32) { - if let Some(tex) = widgets::doc_preview(ui, &format!("arrow:{a:?}:{start}"), r.size(), |w, h| arrow_doc(a, start, w, h)) { +fn paint_arrow(ui: &Ui, r: Rect, a: Option<&Arrowhead>, start: bool, color: Color32) { + if let Some(tex) = widgets::doc_preview(ui, &format!("arrow:{}:{start}", arrow_key(a)), r.size(), |w, h| arrow_doc(a.cloned(), start, w, h)) { ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), color); } } -/// Dropdown whose button and items show drawn arrowhead previews. Returns Some(choice). -fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option, start: bool) -> Option> { +/// Dropdown whose button and items show drawn arrowhead previews: None, the built-in heads, then +/// the `custom` ones. Returns Some(choice). +fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option<&Arrowhead>, custom: &[Arrowhead], start: bool) -> Option> { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(64.0, 24.0), Sense::click()); ui.painter().rect_filled(r, 2, t.input); @@ -380,7 +411,8 @@ fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option, start: bool) -> egui::Popup::menu(&resp).id(egui::Id::unique(("arrow-pop", id))).show(|ui| { ui.set_min_width(170.0); egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - for a in std::iter::once(None).chain(Arrowhead::ALL.map(Some)) { + let built_in = Arrowhead::ALL; + for a in std::iter::once(None).chain(built_in.iter().map(Some)).chain(custom.iter().map(Some)) { let (row, rr) = ui.allocate_exact_size(vec2(170.0, 22.0), Sense::click()); if a == cur { ui.painter().rect_filled(row, 0.0, t.row_selected); @@ -391,12 +423,12 @@ fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option, start: bool) -> ui.painter().text( row.left_center() + vec2(66.0, 0.0), egui::Align2::LEFT_CENTER, - tl!(arrow_label(a)), + arrow_name(a), egui::FontId::proportional(11.5), t.text, ); if rr.clicked() { - out = Some(a); + out = Some(a.cloned()); ui.close(); } } @@ -500,6 +532,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Reset Profiles"), crate::menus::enabled(app, "stroke.widthProfile.reset"), false) { app.run("stroke.widthProfile.reset", json!({})).ok(); } + ui.separator(); + // Opens the arrowheads file, whose symbols are the custom heads; saving it reloads them. + if menu_item(ui, tl!("Edit Arrowheads…"), crate::menus::enabled(app, "stroke.arrowheads.edit"), false) { + app.run("stroke.arrowheads.edit", json!({})).ok(); + } } #[cfg(test)] @@ -647,7 +684,7 @@ mod tests { assert!(silhouette(WidthProfile::PRESETS[0].points).is_none(), "uniform draws the plain bar"); let lens = WidthProfile::lens(); assert_eq!(silhouette(&lens.points), Some(lens.points.as_slice())); - assert_eq!(arrow_label(Some(Arrowhead::CircleOpen)), "Circle (open)"); + assert_eq!(arrow_label(Some(&Arrowhead::CircleOpen)), "Circle (open)"); } #[test] diff --git a/crates/ui-egui/src/panels/tests_strokedepth.rs b/crates/ui-egui/src/panels/tests_strokedepth.rs index e37b6e75b7..c085ccf385 100644 --- a/crates/ui-egui/src/panels/tests_strokedepth.rs +++ b/crates/ui-egui/src/panels/tests_strokedepth.rs @@ -100,9 +100,15 @@ fn every_arrowhead_preview_draws_its_head() { }; let plain = (ink(None, false), ink(None, true)); for a in Arrowhead::ALL { - assert!(ink(Some(a), false) > plain.0 + 20, "{a:?} at the end"); - assert!(ink(Some(a), true) > plain.1 + 20, "{a:?} at the start"); + assert!(ink(Some(a.clone()), false) > plain.0 + 20, "{a:?} at the end"); + assert!(ink(Some(a.clone()), true) > plain.1 + 20, "{a:?} at the start"); } + // So does a custom head: a 4 × 4 square behind its tip. + let pts = [(0.0, -2.0), (4.0, -2.0), (4.0, 2.0), (0.0, 2.0)].map(|(x, y)| vectorcraft_geom::Point::new(x, y)); + let square = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true)); + let custom = Arrowhead::Custom(std::sync::Arc::new(vectorcraft_doc::CustomArrowhead::new("Square".into(), square))); + assert!(ink(Some(custom.clone()), false) > plain.0 + 20); + assert!(ink(Some(custom), true) > plain.1 + 20); } #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..c6a9d363e2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3741,6 +3741,31 @@ the `…Open` rings…) and marks (`Bar`, `DoubleBar`, `DotOnBar`, `Slash`, `Dou `arrowScale`, at least 0.25 pt. `LineArrow` has two arms of the head's weight (the line's weight at 100 % for lines of 0.25 pt or more) at right angles and spreads twice as wide. +Custom arrowheads come from the arrowheads file, `Arrowheads.vectorcraft` next to the preferences (none on the web or +in runs with `VECTORCRAFT_NO_PREFS`). `stroke.arrowheads.edit` (Stroke panel menu › Edit Arrowheads…) opens it, and +first creates it with one example head when it is missing. Each symbol in the file is one head, named after the +symbol and listed after the built-in heads in both arrowhead menus. These rules are VectorCraft's own: a head points +right, the symbol's registration point is its tip, and the art's height is drawn as wide as the built-in heads (four +head weights). The visible fills and the outlined visible strokes of its paths, compound paths and groups, and its +gradient meshes, make the head, with geometry effects applied and clip groups clipped, and the line stops halfway along its length. A head is +one solid shape in the stroke's paint: art at 0 % opacity is left out, and other opacities, blend modes and opacity +masks do not change the shape. The file is read when the app starts and again each time VectorCraft saves it; +`stroke.arrowheads.reload` reads it again. A file larger than 64 MB is not read, at most 256 symbols are read, and a +symbol that paints nothing, reaches more than 64 head weights from its tip, has more than 65,536 anchors in its art or +in the shapes it paints, or has more than 4,096 anchors once united is listed in `skipped`. `stroke.arrowheads.list` returns each head's `name`, `reach` and `halfWidth`, the `file` +path, the `skipped` symbols and any read `error`. + +`stroke.set {endArrow: {"custom": name}}` puts a custom head on a stroke. The stroke keeps a copy of the head's art, +so the document draws it, saves it and exports it to SVG and PDF the same way without the arrowheads file. +`document.inspect` reports such a head as `{"Custom": {name, path}}`, which `stroke.set` takes back unchanged. The +help page [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html) describes +defining custom arrowheads in a separate arrowheads file. + +```json +{"name":"run_command","arguments":{"command":"stroke.arrowheads.list","params":{}}} +{"name":"run_command","arguments":{"command":"stroke.set","params":{"ids":[9],"endArrow":{"custom":"Example Arrowhead"}}}} +``` + ## Stroke bounds and clicks Visual bounds (Fit to Selected Art, Rasterize, exporting all art) and clicks on the canvas take diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 296826671f..15326c0347 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -406,6 +406,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Select › Start Global Edit / Stop Global Edit; Properties › Quick Actions with the Global Edit Options popover (Match: Appearance, Size; On Artboards: All Artboards or a range, Include Objects On Canvas; Result(s)); Escape stops it; the matched objects are highlighted in their layer colour while it runs; a Shift-click on a highlighted match leaves it out | partial | #3521, `select.globalEdit` / `select.globalEditOptions` / `select.globalEditExclude`, `crates/engine/src/tests/globaledit.rs`; paths, compound paths, symbol instances and groups of them (similar groups, #4939: edits inside one group reach the same object in the others; [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-groups-with-similar-objects.html)); Shift-click to leave a match out ([public source](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)); the Control bar's Global Edit Options dropdown opens the same options; rotated copies and highlighting matches before the start remain ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)) | | Contextual task bar | done | | | Control bar with Transform and Stroke popovers | done | | +| Stroke panel: custom arrowheads after the built-in ones in both arrowhead menus, from the arrowheads file that the panel menu's Edit Arrowheads… opens; saving the file reloads the menus, and a stroke keeps a copy of its custom head's art | done | [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html) (Customize arrowheads); the file's layout (one symbol per head, pointing right, tip at the registration point, height drawn as wide as the built-in heads) is VectorCraft's own; `stroke.arrowheads.edit`, `stroke.arrowheads.list`, `stroke.arrowheads.reload`, `stroke.set` `{custom: name}`; engine `tests::arrowheads`, `stroke::tests_arrows::custom_heads_draw_their_own_art_at_the_tip` | | Control bar: Paragraph: link and Align left/center/right while type is selected (#5372) | done | After the Character controls, as the public documentation has the Control panel format paragraphs ([Paragraph panel overview](https://helpx.adobe.com/illustrator/using/formatting-paragraphs.html), [Align text](https://helpx.adobe.com/illustrator/using/align-text.html)); the link opens the Paragraph panel in a popover, as Character: does with the Character panel. The bar trims from its right end: X/Y/W/H, Distribute, Align, area type vertical alignment, then these buttons, then the link. The three-button set is our choice for room; all seven alignments are in the popover. `panels/paragraph/tests.rs` headless tests (`the_control_bar_shows_the_paragraph_alignment_of_the_selected_type`, `the_control_bar_trims_its_paragraph_section_after_the_controls_to_its_right`) | | Pathfinder shape results keep their source names and SVG IDs | done | #1293; Minus Front keeps the back name, Minus Back the front name; Unite/Intersect/Exclude use the backmost named input; compound output carries it once; geometry and paint unchanged | | Attributes fill-rule controls with a compound member selected | done | Edits the compound's rule; direction still edits only the member; rendered-hole and Undo/Redo regression tests (#1197) | From c95a4d901f01cea077732fbf66403fbd72c2ac16 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 23:17:31 -0800 Subject: [PATCH 04/92] Brushes: built-in Ink and Wash library with tapered ink, brush pen and wash brushes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Window › Brush Libraries gains Ink and Wash after Art Ribbons: Tapered Ink, an art brush broad near its start that thins to a fine tip; Brush Pen, a calligraphic brush whose size follows the pen pressure; and Soft Wash and Pooling Wash, art brushes of translucent gray layers. The art brushes take the stroke color as tints. The names, shapes and opacities are VectorCraft's own. Docs: docs/mcp.md, docs/gaps.md, docs/roadmap.md, docs/target-app-parity.md. --- crates/engine/src/cmd/brushlib.rs | 73 ++++++++ crates/engine/src/tests/brushlib.rs | 158 +++++++++++++++++- crates/ui-egui/src/i18n/bg.tsv | 5 + crates/ui-egui/src/i18n/bs.tsv | 5 + crates/ui-egui/src/i18n/ca.tsv | 5 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/da.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fi.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/hi.tsv | 5 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/id.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ka.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/nl.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/sv.tsv | 5 + crates/ui-egui/src/i18n/tr.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/vi.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + .../src/panels/tests_brush_libraries.rs | 21 +++ docs/gaps.md | 4 +- docs/mcp.md | 2 +- docs/roadmap.md | 2 +- docs/target-app-parity.md | 8 +- 34 files changed, 391 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 300d9f6aa1..40ffb7840a 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -168,6 +168,7 @@ pub struct BuiltinBrushLibrary { /// The built-in brush libraries, in menu order: original brushes generated in code. pub const BUILTIN: &[BuiltinBrushLibrary] = &[ BuiltinBrushLibrary { id: "art-ribbons", name: "Art Ribbons", make: art_ribbons }, + BuiltinBrushLibrary { id: "ink-and-wash", name: "Ink and Wash", make: ink_and_wash }, BuiltinBrushLibrary { id: "calligraphic-nibs", name: "Calligraphic Nibs", make: calligraphic_nibs }, BuiltinBrushLibrary { id: "pattern-borders", name: "Pattern Borders", make: pattern_borders }, BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes }, @@ -264,6 +265,78 @@ fn art_ribbons() -> BrushLibrary { built("Art Ribbons", doc, brushes) } +/// A band between the edges y = `top(x)` and y = `bottom(x)` for x in `from`..`to`, in `n` steps. +fn between(from: f64, to: f64, n: usize, top: impl Fn(f64) -> f64, bottom: impl Fn(f64) -> f64) -> PathData { + let xs: Vec = (0..=n).map(|i| from + (to - from) * i as f64 / n as f64).collect(); + let mut pts: Vec = xs.iter().map(|&x| Point::new(x, top(x))).collect(); + pts.extend(xs.iter().rev().map(|&x| Point::new(x, bottom(x)))); + PathData::single(SubPath::polyline(&pts, true)) +} + +/// Path `p` filled with gray `hex` at `opacity`: one layer of a wash. +fn wash(p: PathData, hex: &str, opacity: f32) -> Node { + let mut n = filled(p, Paint::solid(Color::from_hex(hex).unwrap_or(Color::BLACK))); + n.opacity = opacity; + n +} + +/// How much of a wash's width is left at x: rounded ends, full width from 8 pt in. +fn wash_ends(x: f64) -> f64 { + let ends = ((x / 8.0).min(1.0) * ((100.0 - x) / 8.0).min(1.0)).max(0.0); + 0.4 + 0.6 * ends.sqrt() +} + +/// Ink and Wash: Tapered Ink, broad near its start and thinning to a fine tip; Brush Pen, a +/// rounded nib whose size follows the pen pressure; and two washes of translucent grays, Soft Wash +/// with faint edges and Pooling Wash with a darker rim and two light breaks. Black and grays take +/// the stroke color as tints of it. +fn ink_and_wash() -> BrushLibrary { + let tapered = { + let half = |x: f64| 3.0 * (x / 22.0).min(1.0).powf(0.6) * ((100.0 - x) / 78.0).min(1.0).powf(1.4); + black(between(0.0, 100.0, 48, |x| -0.85 * half(x), |x| 1.15 * half(x))) + }; + let pen = Brush { + name: "Brush Pen".into(), + kind: BrushKind::Calligraphic(Calligraphic { + angle: 40.0, + roundness: 75.0, + size: 5.0, + variation: [0.0, 0.0, 2.5], + modes: Some([Variation::Fixed, Variation::Fixed, Variation::Pressure]), + }), + }; + let soft = { + let top = |x: f64| -(4.5 + 0.5 * (x * 0.21).sin() + 0.3 * (x * 0.57 + 1.3).sin()) * wash_ends(x); + let bottom = |x: f64| (4.5 + 0.45 * (x * 0.26 + 2.1).sin() + 0.3 * (x * 0.69 + 0.4).sin()) * wash_ends(x); + group(vec![ + wash(between(0.0, 100.0, 40, top, bottom), "#404040", 0.4), + wash(between(2.0, 98.0, 32, |x| top(x) - 0.7 - 0.3 * (x * 0.43).sin().abs(), |x| top(x) + 0.5), "#404040", 0.2), + wash(between(3.0, 97.0, 32, |x| bottom(x) - 0.5, |x| bottom(x) + 0.6 + 0.35 * (x * 0.37 + 1.0).sin().abs()), "#404040", 0.18), + wash(shapes::ellipse(Rect::new(21.0, -2.4, 39.0, 0.8)), "#e6e6e6", 0.22), + wash(shapes::ellipse(Rect::new(61.0, -0.3, 75.0, 2.3)), "#e6e6e6", 0.18), + ]) + }; + let pooling = { + let top = |x: f64| -(4.0 + 0.4 * (x * 0.18 + 0.7).sin() + 0.2 * (x * 0.53).sin()) * wash_ends(x); + let bottom = |x: f64| (4.0 + 0.35 * (x * 0.23 + 1.9).sin() + 0.25 * (x * 0.61 + 0.2).sin()) * wash_ends(x); + let rim = |x: f64, k: f64, phase: f64| 0.35 + k * (0.5 + 0.5 * (x * 0.29 + phase).sin()); + group(vec![ + wash(between(0.0, 100.0, 40, top, bottom), "#4d4d4d", 0.35), + wash(between(1.0, 99.0, 40, top, |x| top(x) + rim(x, 0.45, 0.0)), "#262626", 0.55), + wash(between(1.0, 99.0, 40, |x| bottom(x) - rim(x, 0.5, 1.1), bottom), "#262626", 0.55), + wash(polygon(&[(34.0, -2.6), (39.0, -2.8), (36.0, 2.7), (31.0, 2.5)]), "#e6e6e6", 0.2), + wash(polygon(&[(70.0, -2.4), (74.0, -2.5), (72.0, 2.6), (68.0, 2.4)]), "#e6e6e6", 0.18), + ]) + }; + let brushes = vec![ + art("Tapered Ink", tapered, Colorization::Tints), + pen, + art("Soft Wash", soft, Colorization::Tints), + art("Pooling Wash", pooling, Colorization::Tints), + ]; + built("Ink and Wash", Document::new(100.0, 100.0), brushes) +} + fn nib(name: &str, angle: f64, roundness: f64, size: f64) -> Brush { Brush { name: name.into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, ..Default::default() }) } } diff --git a/crates/engine/src/tests/brushlib.rs b/crates/engine/src/tests/brushlib.rs index 491a471088..03f5c5aeb8 100644 --- a/crates/engine/src/tests/brushlib.rs +++ b/crates/engine/src/tests/brushlib.rs @@ -104,7 +104,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { let list = run(&mut s, "brush.libraries", json!({})); let libs = list["libraries"].as_array().unwrap(); let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect(); - assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams"]); + assert_eq!(ids, ["art-ribbons", "ink-and-wash", "calligraphic-nibs", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams"]); assert!(libs.iter().all(|l| l["category"] == "builtIn")); let lib = run(&mut s, "brush.library", json!({"library": "art ribbons"})); assert_eq!( @@ -117,20 +117,22 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { trails["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::>(), ["Speed Line", "Dash Trail", "Burst Ray", "Zigzag"] ); - assert_eq!(libs[4]["count"].as_u64(), Some(4)); + assert_eq!(libs[5]["count"].as_u64(), Some(4)); let beams = run(&mut s, "brush.library", json!({"library": "energy-beams"})); assert_eq!( beams["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::>(), ["Laser Bolt", "Plasma Arc", "Charge Wave", "Spark Trail"] ); - assert_eq!(libs[5]["count"].as_u64(), Some(4)); + assert_eq!(libs[6]["count"].as_u64(), Some(4)); run(&mut s, "file.new", json!({"width": 200, "height": 200})); let id = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 8}))["id"].as_u64().unwrap(); let added = run(&mut s, "brush.addFromLibrary", json!({"library": "energy-beams", "name": "Laser Bolt", "apply": true, "ids": [id]})); assert_eq!(strs(&added["added"]), ["Laser Bolt"]); - // One kind of brush per library, every brush drawing something. + // One kind of brush per library (Ink and Wash: art brushes and a calligraphic one), every + // brush drawing something. for (id, kind) in [ ("art-ribbons", "art"), + ("ink-and-wash", "art"), ("calligraphic-nibs", "calligraphic"), ("pattern-borders", "pattern"), ("scatter-shapes", "scatter"), @@ -140,6 +142,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { let (_, lib) = cmd::brushlib::library(&s, id).unwrap(); assert!(lib.len() >= 4, "{id}"); for b in &lib.brushes { + let kind = if b.name == "Brush Pen" { "calligraphic" } else { kind }; assert_eq!(b.kind.type_id(), kind, "{}", b.name); let mut line = vectorcraft_geom::BezPath::new(); line.move_to((0.0, 0.0)); @@ -165,6 +168,153 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { assert!(s.execute("brush.library", &json!({"library": "nope"})).is_err()); } +/// Every path of brush art `n`: (anchors, opacity). +fn art_paths(n: &vectorcraft_doc::Node) -> Vec<(usize, f32)> { + let mut out = vec![]; + n.walk(&mut |c| { + if let Some(p) = c.path_data() { + out.push((p.anchor_count(), c.opacity)); + } + }); + out +} + +/// The Ink and Wash library, made once. +fn inks(s: &Session) -> std::sync::Arc { + cmd::brushlib::library(s, "ink-and-wash").unwrap().1 +} + +/// A straight line from the origin to (`x`, 0). +fn line_to(x: f64) -> vectorcraft_geom::BezPath { + let mut line = vectorcraft_geom::BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((x, 0.0)); + line +} + +fn black_stroke() -> vectorcraft_doc::StrokeLayer { + vectorcraft_doc::StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 1.0) +} + +/// Ink and Wash: a tapered ink art brush, a brush pen whose size follows the pen pressure and two +/// translucent washes, with names of their own and a few paths of art each. +#[test] +fn the_ink_and_wash_library_has_tapered_ink_a_brush_pen_and_two_washes() { + let mut s = session(); + let lib = run(&mut s, "brush.library", json!({"library": "ink-and-wash"})); + assert_eq!((lib["name"].as_str(), lib["category"].as_str()), (Some("Ink and Wash"), Some("builtIn"))); + let listed: Vec<(&str, &str)> = + lib["brushes"].as_array().unwrap().iter().map(|b| (b["name"].as_str().unwrap(), b["type"].as_str().unwrap())).collect(); + assert_eq!(listed, [("Tapered Ink", "art"), ("Brush Pen", "calligraphic"), ("Soft Wash", "art"), ("Pooling Wash", "art")]); + assert_eq!(run(&mut s, "brush.library", json!({"library": "Ink and Wash"}))["id"], "ink-and-wash"); + let inks = inks(&s); + // Names no other built-in library uses. + for b in cmd::brushlib::BUILTIN.iter().filter(|b| b.id != "ink-and-wash") { + let (_, other) = cmd::brushlib::library(&s, b.id).unwrap(); + assert!(inks.brushes.iter().all(|x| other.brush(&x.name).is_none()), "{}", b.id); + } + // The art: within its 100 pt length, a few paths each, opaque ink and translucent washes. + let mut anchors = 0; + for name in ["Tapered Ink", "Soft Wash", "Pooling Wash"] { + let BrushKind::Art(a) = &inks.brush(name).unwrap().kind else { panic!("{name} is not an art brush") }; + assert_eq!((a.scale, a.colorization), (vectorcraft_brush::ArtScale::Stretch, vectorcraft_brush::Colorization::Tints), "{name}"); + let b = a.art.geometric_bounds().unwrap(); + assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()), "{name}"); + assert!(b.x0 >= 0.0 && b.x1 <= 100.0 && b.x1 - b.x0 > 99.0, "{name}: {b:?}"); + assert!(b.y0 >= -8.0 && b.y1 <= 8.0 && b.y1 - b.y0 > 5.0, "{name}: {b:?}"); + let paths = art_paths(&a.art); + let count: usize = paths.iter().map(|p| p.0).sum(); + assert!(paths.len() <= 80 && count <= 500, "{name}: {} paths, {count} anchors", paths.len()); + anchors += count; + let opacities: Vec = paths.iter().map(|p| p.1).collect(); + if name == "Tapered Ink" { + assert_eq!(opacities, [1.0], "opaque ink"); + } else { + assert!(paths.len() >= 3, "{name}: a wash of several layers"); + assert!(opacities.iter().all(|o| (0.15..=0.55).contains(o)), "{name}: {opacities:?}"); + } + } + assert!(anchors <= 1200, "{anchors} anchors"); + let BrushKind::Calligraphic(pen) = &inks.brush("Brush Pen").unwrap().kind else { panic!("Brush Pen is not calligraphic") }; + use vectorcraft_brush::Variation::{Fixed, Pressure}; + assert_eq!((pen.size, pen.modes(), pen.variation), (5.0, [Fixed, Fixed, Pressure], [0.0, 0.0, 2.5]), "no random variation"); +} + +/// Every Ink and Wash brush draws along straight, curved, short and tight paths within finite +/// bounds, and Tapered Ink is broad near its start and fine at its end. +#[test] +fn ink_and_wash_brushes_draw_along_straight_curved_short_and_tight_paths() { + let inks = inks(&Session::new()); + let mut curve = vectorcraft_geom::BezPath::new(); + curve.move_to((0.0, 0.0)); + curve.curve_to((60.0, -40.0), (140.0, 40.0), (200.0, 0.0)); + let tight = vectorcraft_geom::shapes::ellipse(vectorcraft_geom::Rect::new(0.0, 0.0, 6.0, 6.0)).to_bezpath(); + let paths = [line_to(200.0), line_to(3.0), curve, tight]; + for b in &inks.brushes { + for line in &paths { + let pieces = vectorcraft_brush::stroke_pieces(&inks.doc, b, line, &black_stroke()); + assert!(!pieces.is_empty(), "{} draws nothing", b.name); + let bounds: Vec<_> = pieces.iter().map(|p| p.geometric_bounds()).collect(); + assert!(bounds.iter().all(|r| r.is_some_and(|r| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()))), "{}", b.name); + } + } + let pieces = vectorcraft_brush::stroke_pieces(&inks.doc, inks.brush("Tapered Ink").unwrap(), &line_to(100.0), &black_stroke()); + let widest = |from: f64, to: f64| { + pieces + .iter() + .filter_map(|n| n.path_data()) + .flat_map(|p| p.anchors().map(|(_, _, a)| a.p).collect::>()) + .filter(|p| p.x >= from && p.x <= to) + .map(|p| p.y.abs()) + .fold(0.0, f64::max) + }; + assert!(widest(15.0, 30.0) > 4.0 * widest(95.0, 100.0), "broad start, fine tip"); +} + +/// Ink and Wash brushes are added and applied in one step, undone and redone, not added twice, +/// and kept with the brushed stroke by a saved and reopened document. +#[test] +fn ink_and_wash_brushes_add_apply_undo_and_survive_a_saved_document() { + let mut s = session(); + let inks = inks(&s); + let path = run(&mut s, "shape.line", json!({"x1": 10, "y1": 10, "x2": 150, "y2": 60}))["id"].as_u64().unwrap(); + let (defaults, before) = (brushes(&s), undo_len(&s)); + let names = ["Tapered Ink", "Brush Pen", "Soft Wash", "Pooling Wash"]; + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "ink-and-wash", "name": "Soft Wash", "apply": true, "ids": [path]})); + assert_eq!((strs(&r["added"]), r["applied"].clone(), undo_len(&s)), (vec!["Soft Wash"], json!([path]), before + 1)); + let stroke_brush = |s: &Session| doc(s).node(NodeId(path)).unwrap().appearance.stroke().and_then(|st| st.brush.clone()); + assert_eq!(stroke_brush(&s).as_deref(), Some("Soft Wash")); + run(&mut s, "edit.undo", json!({})); + assert_eq!((brushes(&s), stroke_brush(&s)), (defaults.clone(), None)); + run(&mut s, "edit.redo", json!({})); + assert_eq!(stroke_brush(&s).as_deref(), Some("Soft Wash")); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "ink-and-wash"})); + assert_eq!( + (strs(&r["names"]), strs(&r["existing"]), strs(&r["added"])), + (names.to_vec(), vec!["Soft Wash"], vec!["Tapered Ink", "Brush Pen", "Pooling Wash"]) + ); + let before = undo_len(&s); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "ink-and-wash"})); + assert_eq!((strs(&r["existing"]), undo_len(&s)), (names.to_vec(), before)); + assert_eq!(brushes(&s).len(), defaults.len() + 4); + for n in names { + assert_eq!(&brush(&s, n), inks.brush(n).unwrap(), "{n}"); + } + + // Saved and opened again, the document keeps the definitions and the brushed stroke. + let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string(); + let reopened = vectorcraft_format::load(&vectorcraft_format::base64_decode(&b64).unwrap()).unwrap(); + for n in names { + assert_eq!(vectorcraft_brush::find(&reopened, n).as_ref(), inks.brush(n), "{n}"); + } + let n = reopened.node(NodeId(path)).unwrap(); + assert_eq!(n.appearance.stroke().and_then(|st| st.brush.clone()).as_deref(), Some("Soft Wash")); + assert_eq!( + vectorcraft_brush::node_pieces(&reopened, n).map(|p| p.len()), + vectorcraft_brush::node_pieces(doc(&s), doc(&s).node(NodeId(path)).unwrap()).map(|p| p.len()) + ); +} + #[test] fn a_saved_library_reopens_and_its_brushes_join_another_document_in_one_step() { let dir = folder("user"); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 6b770ec1bc..312df6669d 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6025,6 +6025,11 @@ axis Y Axis Y ос User Brush Library 9 Потребителска библиотека с четки 9 User Brush Library 10 Потребителска библиотека с четки 10 Art Ribbons Арт ленти + Ink and Wash Туш и лавиране + Tapered Ink Изтъняващ туш + Brush Pen Писалка-четка + Soft Wash Меко лавиране + Pooling Wash Лавиране с ръб Zigzag Зигзаг Burst Ray Лъч на удар Dash Trail Пунктирна следа diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index f6029f08ef..f3186f3ad5 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6075,6 +6075,11 @@ axis Y Axis Y osa User Brush Library 9 Korisnička biblioteka četkica 9 User Brush Library 10 Korisnička biblioteka četkica 10 Art Ribbons Umjetničke trake + Ink and Wash Tuš i lavir + Tapered Ink Suženi tuš + Brush Pen Kistasti flomaster + Soft Wash Meki lavir + Pooling Wash Lavir s rubom Zigzag Cik-cak Burst Ray Zraka udara Dash Trail Isprekidani trag diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index cf0b677271..1ceeea242e 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -363,6 +363,11 @@ antiAlias Sharp Nítid Art Brush Options Opcions del pinzell artístic Art Optimized Optimitzat per a l’art Art Ribbons Cintes d’art + Ink and Wash Tinta i aiguada + Tapered Ink Tinta afuada + Brush Pen Retolador de pinzell + Soft Wash Aiguada suau + Pooling Wash Aiguada amb vora Zigzag Zigzag Burst Ray Raig d’impacte Dash Trail Estela discontínua diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9125a4f05f..a1fab26f85 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6276,6 +6276,11 @@ axis Y Axis osa Y User Brush Library 9 Uživatelská knihovna štětců 9 User Brush Library 10 Uživatelská knihovna štětců 10 Art Ribbons Obrazové stuhy + Ink and Wash Tuš a lavírování + Tapered Ink Zužující se tuš + Brush Pen Štětcový fix + Soft Wash Jemná lavírka + Pooling Wash Lavírka s okrajem Zigzag Cikcak Burst Ray Paprsek nárazu Dash Trail Čárkovaná stopa diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 00b46b0f22..83d85cc29a 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6040,6 +6040,11 @@ axis Y Axis Y-akse User Brush Library 9 Brugerpenselbibliotek 9 User Brush Library 10 Brugerpenselbibliotek 10 Art Ribbons Kunstbånd + Ink and Wash Blæk og lavering + Tapered Ink Tilspidset blæk + Brush Pen Penselpen + Soft Wash Blød lavering + Pooling Wash Lavering med rand Zigzag Zigzag Burst Ray Burststråle Dash Trail Streget spor diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ec8d91ad1c..5f395e36c2 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5867,6 +5867,11 @@ axis Y Axis Y-Achse User Brush Library 9 Benutzer-Pinsel-Bibliothek 9 User Brush Library 10 Benutzer-Pinsel-Bibliothek 10 Art Ribbons Bildbänder + Ink and Wash Tusche und Lavur + Tapered Ink Spitz zulaufende Tusche + Brush Pen Pinselstift + Soft Wash Weiche Lavur + Pooling Wash Lavur mit Farbrand Zigzag Zickzack Burst Ray Stoßstrahl Dash Trail Strichelspur diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 17527f5871..a122d49959 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6227,6 +6227,11 @@ axis Y Axis Άξονας Y User Brush Library 9 Βιβλιοθήκη πινέλων χρήστη 9 User Brush Library 10 Βιβλιοθήκη πινέλων χρήστη 10 Art Ribbons Γραφικές κορδέλες + Ink and Wash Μελάνι και ακουαρέλα + Tapered Ink Λεπτυνόμενο μελάνι + Brush Pen Πένα πινέλου + Soft Wash Απαλή ακουαρέλα + Pooling Wash Ακουαρέλα με περίγραμμα Zigzag Ζιγκζάγκ Burst Ray Ακτίνα κρούσης Dash Trail Διακεκομμένο ίχνος diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index f4ea5ecf80..5e83437a9a 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6218,6 +6218,11 @@ axis Y Axis Eje Y User Brush Library 9 Biblioteca de pinceles del usuario 9 User Brush Library 10 Biblioteca de pinceles del usuario 10 Art Ribbons Cintas de ilustración + Ink and Wash Tinta y aguada + Tapered Ink Tinta afilada + Brush Pen Rotulador de pincel + Soft Wash Aguada suave + Pooling Wash Aguada con borde Zigzag Zigzag Burst Ray Rayo de impacto Dash Trail Estela discontinua diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index b7a34058f4..0dd4c21738 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6306,6 +6306,11 @@ axis Y Axis Y-akseli User Brush Library 9 Käyttäjän sivellinkirjasto 9 User Brush Library 10 Käyttäjän sivellinkirjasto 10 Art Ribbons Kuvitusnauhat + Ink and Wash Muste ja laveeraus + Tapered Ink Suippeneva muste + Brush Pen Pensselikynä + Soft Wash Pehmeä laveeraus + Pooling Wash Reunustettu laveeraus Zigzag Siksak Burst Ray Iskusäde Dash Trail Katkoviiva diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 36bf674ef2..a9c4c80bcc 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6271,6 +6271,11 @@ axis Y Axis Axe Y User Brush Library 9 Bibliothèque de formes utilisateur 9 User Brush Library 10 Bibliothèque de formes utilisateur 10 Art Ribbons Rubans d'illustration + Ink and Wash Encre et lavis + Tapered Ink Encre effilée + Brush Pen Feutre pinceau + Soft Wash Lavis doux + Pooling Wash Lavis à auréole Zigzag Zigzag Burst Ray Rayon d’impact Dash Trail Traînée tiretée diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 7dbeabac1e..43818a5673 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -424,6 +424,11 @@ antiAlias Sharp शार्प Art Brush Options आर्ट ब्रश ऑप्शन्स Art Optimized आर्टवर्क के लिए अनुकूलित Art Ribbons आर्ट रिबन + Ink and Wash स्याही और वॉश + Tapered Ink पतली होती स्याही + Brush Pen ब्रश पेन + Soft Wash सॉफ्ट वॉश + Pooling Wash पूलिंग वॉश Zigzag ज़िगज़ैग Burst Ray बर्स्ट रे Dash Trail डैश ट्रेल diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c87d42e6dc..84ffbba64f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6259,6 +6259,11 @@ axis Y Axis Y tengely User Brush Library 9 Felhasználói ecsetkönyvtár 9 User Brush Library 10 Felhasználói ecsetkönyvtár 10 Art Ribbons Grafikus szalagok + Ink and Wash Tus és lavírozás + Tapered Ink Elvékonyodó tus + Brush Pen Ecsetfilc + Soft Wash Lágy lavírozás + Pooling Wash Szegélyes lavírozás Zigzag Cikkcakk Burst Ray Ütéssugár Dash Trail Szaggatott nyom diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 9925f7b0d8..0b110cbaf9 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6040,6 +6040,11 @@ axis Y Axis Sumbu Y User Brush Library 9 Pustaka kuas pengguna 9 User Brush Library 10 Pustaka kuas pengguna 10 Art Ribbons Pita seni + Ink and Wash Tinta dan sapuan + Tapered Ink Tinta meruncing + Brush Pen Pena kuas + Soft Wash Sapuan lembut + Pooling Wash Sapuan menggenang Zigzag Zigzag Burst Ray Sinar ledakan Dash Trail Jejak putus-putus diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 36c8820296..88151b67d2 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5901,6 +5901,11 @@ axis Y Axis Asse Y User Brush Library 9 Libreria pennelli utente 9 User Brush Library 10 Libreria pennelli utente 10 Art Ribbons Nastri grafici + Ink and Wash Inchiostro e acquerellato + Tapered Ink Inchiostro affusolato + Brush Pen Pennarello a pennello + Soft Wash Velatura morbida + Pooling Wash Velatura con bordo Zigzag Zigzag Burst Ray Raggio d’impatto Dash Trail Scia tratteggiata diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6dcabf27e8..8fa2f9751d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5966,6 +5966,11 @@ axis Y Axis Y 軸 User Brush Library 9 ユーザーブラシライブラリ 9 User Brush Library 10 ユーザーブラシライブラリ 10 Art Ribbons アートリボン + Ink and Wash 墨と淡彩 + Tapered Ink 先細りの墨 + Brush Pen 筆ペン + Soft Wash 柔らかい淡彩 + Pooling Wash たまりのある淡彩 Zigzag ジグザグ Burst Ray バーストレイ Dash Trail ダッシュトレイル diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 23a18557fa..24a8023c6d 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6227,6 +6227,11 @@ axis Y Axis Y ღერძი User Brush Library 9 მომხმარებლის ფუნჯების ბიბლიოთეკა 9 User Brush Library 10 მომხმარებლის ფუნჯების ბიბლიოთეკა 10 Art Ribbons ნახატის ლენტები + Ink and Wash მელანი და ლავირება + Tapered Ink წვეტიანი მელანი + Brush Pen ფუნჯ-კალამი + Soft Wash რბილი ლავირება + Pooling Wash კიდიანი ლავირება Zigzag ზიგზაგი Burst Ray დარტყმის სხივი Dash Trail წყვეტილი კვალი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 5366483cd9..62de80acae 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5862,6 +5862,11 @@ axis Y Axis Y축 User Brush Library 9 사용자 브러시 라이브러리 9 User Brush Library 10 사용자 브러시 라이브러리 10 Art Ribbons 아트 리본 + Ink and Wash 먹과 담채 + Tapered Ink 가늘어지는 먹선 + Brush Pen 붓펜 + Soft Wash 부드러운 담채 + Pooling Wash 고임 담채 Zigzag 지그재그 Burst Ray 버스트 레이 Dash Trail 대시 트레일 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 20ea6ac94d..929424839d 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6091,6 +6091,11 @@ axis Y Axis Y-as User Brush Library 9 Penseelbibliotheek van gebruiker 9 User Brush Library 10 Penseelbibliotheek van gebruiker 10 Art Ribbons Illustratielinten + Ink and Wash Inkt en wassing + Tapered Ink Taps toelopende inkt + Brush Pen Penseelstift + Soft Wash Zachte wassing + Pooling Wash Wassing met rand Zigzag Zigzag Burst Ray Inslagstraal Dash Trail Streepspoor diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8d72f58fa4..da2b8be141 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6130,6 +6130,11 @@ axis Y Axis Oś Y User Brush Library 9 Biblioteka pędzli użytkownika 9 User Brush Library 10 Biblioteka pędzli użytkownika 10 Art Ribbons Wstęgi artystyczne + Ink and Wash Tusz i laworowanie + Tapered Ink Zwężający się tusz + Brush Pen Pisak pędzelkowy + Soft Wash Miękkie laworowanie + Pooling Wash Laworowanie z obwódką Zigzag Zygzak Burst Ray Promień uderzenia Dash Trail Ślad kreskowany diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index eb2c29947a..07fa9bf299 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6191,6 +6191,11 @@ axis Y Axis Eixo Y User Brush Library 9 Biblioteca de Pincéis do Usuário 9 User Brush Library 10 Biblioteca de Pincéis do Usuário 10 Art Ribbons Fitas de Arte + Ink and Wash Nanquim e Aguada + Tapered Ink Nanquim Afilado + Brush Pen Caneta Pincel + Soft Wash Aguada Suave + Pooling Wash Aguada com Borda Zigzag Zigue-zague Burst Ray Raio de impacto Dash Trail Rastro tracejado diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 533192c53a..58a4a44947 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5887,6 +5887,11 @@ axis Y Axis Ось Y User Brush Library 9 Пользовательская библиотека кистей 9 User Brush Library 10 Пользовательская библиотека кистей 10 Art Ribbons Художественные ленты + Ink and Wash Тушь и размывка + Tapered Ink Сужающаяся тушь + Brush Pen Ручка-кисть + Soft Wash Мягкая размывка + Pooling Wash Размывка с ободком Zigzag Зигзаг Burst Ray Ударный луч Dash Trail Пунктирный след diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 558238e60e..e9d9487dda 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6294,6 +6294,11 @@ axis Y Axis Y-axel User Brush Library 9 Användarbibliotek för penslar 9 User Brush Library 10 Användarbibliotek för penslar 10 Art Ribbons Konstband + Ink and Wash Tusch och lavering + Tapered Ink Avsmalnande tusch + Brush Pen Penselpenna + Soft Wash Mjuk lavering + Pooling Wash Lavering med kant Zigzag Sicksack Burst Ray Stötstråle Dash Trail Streckat spår diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f88726b3fc..89c8552720 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6232,6 +6232,11 @@ axis Y Axis Y ekseni User Brush Library 9 Kullanıcı Fırça Kitaplığı 9 User Brush Library 10 Kullanıcı Fırça Kitaplığı 10 Art Ribbons Sanat Şeritleri + Ink and Wash Mürekkep ve Lavi + Tapered Ink Sivrilen Mürekkep + Brush Pen Fırça Kalem + Soft Wash Yumuşak Lavi + Pooling Wash Kenarlı Lavi Zigzag Zigzag Burst Ray Darbe ışını Dash Trail Kesik iz diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8038529844..2c6f0ffec0 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5881,6 +5881,11 @@ axis Y Axis Вісь Y User Brush Library 9 Користувацька бібліотека пензлів 9 User Brush Library 10 Користувацька бібліотека пензлів 10 Art Ribbons Художні стрічки + Ink and Wash Туш і розмивка + Tapered Ink Звужена туш + Brush Pen Пензлева ручка + Soft Wash М'яка розмивка + Pooling Wash Розмивка з облямівкою Zigzag Зигзаг Burst Ray Ударний промінь Dash Trail Пунктирний слід diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6178748b4f..e5de265edc 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5882,6 +5882,11 @@ axis Y Axis Trục Y User Brush Library 9 Thư viện cọ người dùng 9 User Brush Library 10 Thư viện cọ người dùng 10 Art Ribbons Dải băng nghệ thuật + Ink and Wash Mực và màu loang + Tapered Ink Mực vuốt nhọn + Brush Pen Bút lông + Soft Wash Màu loang mềm + Pooling Wash Màu loang đọng viền Zigzag Zigzag Burst Ray Tia va chạm Dash Trail Vệt đứt đoạn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 20ae4298b6..9893f54765 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6114,6 +6114,11 @@ axis Y Axis Y 轴 User Brush Library 9 用户画笔库 9 User Brush Library 10 用户画笔库 10 Art Ribbons 艺术丝带 + Ink and Wash 水墨淡彩 + Tapered Ink 渐细墨线 + Brush Pen 软头笔 + Soft Wash 柔和淡彩 + Pooling Wash 积色淡彩 Zigzag 锯齿 Burst Ray 冲击射线 Dash Trail 虚线轨迹 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ee57010551..f42668065f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5919,6 +5919,11 @@ axis Y Axis Y 軸 User Brush Library 9 使用者筆刷資料庫 9 User Brush Library 10 使用者筆刷資料庫 10 Art Ribbons 藝術緞帶 + Ink and Wash 水墨淡彩 + Tapered Ink 漸細墨線 + Brush Pen 軟頭筆 + Soft Wash 柔和淡彩 + Pooling Wash 積色淡彩 Zigzag 鋸齒 Burst Ray 衝擊射線 Dash Trail 虛線軌跡 diff --git a/crates/ui-egui/src/panels/tests_brush_libraries.rs b/crates/ui-egui/src/panels/tests_brush_libraries.rs index 6918e9118f..a937100076 100644 --- a/crates/ui-egui/src/panels/tests_brush_libraries.rs +++ b/crates/ui-egui/src/panels/tests_brush_libraries.rs @@ -120,6 +120,26 @@ fn clicking_a_library_brush_adds_it_to_the_documents_brushes_in_one_step() { assert!(last_open(&app.ui).is_none()); } +#[test] +fn the_ink_and_wash_library_opens_and_a_click_adds_and_applies_its_brush() { + let mut app = app(); + let ctx = context(); + let r = app.run("window.brushLibrary", json!({"library": "ink-and-wash"})).unwrap(); + assert_eq!((r["open"].as_str(), r["count"].as_u64()), (Some("ink-and-wash"), Some(4))); + let lib = BrushLibraries::get(&app, "ink-and-wash").unwrap().1; + let rows = BrushLibraries::rows(&lib, ""); + assert_eq!(rows.iter().map(|r| r.name).collect::>(), ["Tapered Ink", "Brush Pen", "Soft Wash", "Pooling Wash"]); + let path = app.run("shape.line", json!({"x1": 10, "y1": 10, "x2": 150, "y2": 60})).unwrap()["id"].as_u64().unwrap(); + let (undo, brushes) = (undo_len(&app), count(&app)); + click(&mut app, &ctx, "Pooling Wash", Modifiers::NONE, 0.0); + assert_eq!((count(&app), undo_len(&app)), (brushes + 1, undo + 1), "one brush, one undo step"); + let n = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(path)).unwrap().clone(); + assert_eq!(n.appearance.stroke().and_then(|s| s.brush.clone()).as_deref(), Some("Pooling Wash")); + click(&mut app, &ctx, "Brush Pen", Modifiers::NONE, 2.0); + assert!(has(&app, "Brush Pen")); + assert_eq!(app.run("brush.list", json!({})).unwrap()["current"], "Brush Pen"); +} + #[test] fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { let mut app = app(); @@ -131,6 +151,7 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { opened, [ json!("art-ribbons"), + json!("ink-and-wash"), json!("calligraphic-nibs"), json!("pattern-borders"), json!("scatter-shapes"), diff --git a/docs/gaps.md b/docs/gaps.md index 3a72e15433..ea11f8273a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -631,7 +631,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi libraries ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html)): Window › Brush Libraries and the Brushes panel's Brush Libraries Menu open each library in a library panel of - its own (six original libraries generated in code: Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter + its own (seven original libraries generated in code: Art Ribbons, Ink and Wash, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails and Energy Beams; User Defined; Other Library… reads the brushes of any document VectorCraft opens); a click adds a brush to the document's Brushes with the patterns, symbols and other resources its art uses, as one undo @@ -651,7 +651,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi with Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade and Shield Buff, and Textures with Fine Grain, Canvas Weave, Burlap Sack, Cracked Glaze, Mosaic Floor, Quilted Squares, Leaded Glass and Damp Paper, painted with the texture effects; User Defined; Other Library…; `graphicStyle.libraries`, `graphicStyle.addFromLibrary`). -- **Missing:** more built-in brush libraries beyond the six shipped (and further graphic-style libraries beyond the nine shipped, and further symbol libraries beyond Basic +- **Missing:** more built-in brush libraries beyond the seven shipped (and further graphic-style libraries beyond the nine shipped, and further symbol libraries beyond Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; Global Edit of groups holding type or images, rotated copies as matches and highlighting matches before the start; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). diff --git a/docs/mcp.md b/docs/mcp.md index b9c23ad2d1..d9f29df7d5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3429,7 +3429,7 @@ error. ## Brush libraries -Brush libraries are read-only sets of brushes: six generated in code (Art Ribbons, Calligraphic Nibs, Pattern +Brush libraries are read-only sets of brushes: seven generated in code (Art Ribbons, Ink and Wash, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams), the `.vectorcraft` files in the user library folder of the desktop app (User Defined) and the brushes of any document loaded with `brush.loadLibrary` (a document without brushes of its own has the default ones). `brush.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and diff --git a/docs/roadmap.md b/docs/roadmap.md index cf782106a1..7f859d5b8a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -149,7 +149,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | -| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, six generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | +| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, seven generated libraries including Ink and Wash, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369); Live Paint Bucket fills faces crossed in one drag (#6373) | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; `script.run` and File › Scripts › Other Script… run JavaScript files, and Window › Script Console shows their output; the script document model and batch work remain | 18–27 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9c58bcaa8c..b3d65f9ee2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -220,7 +220,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered; Live Corners' Relative rounding done (#3091), score unchanged; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123), score and hours unchanged | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; 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 (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522); Live Paint Bucket face drags, including narrow faces and one Undo, regression-tested (#6373) | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | Eyedropper copies effective character dimensions across text transforms (#6404); 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors| 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | -| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (six generated libraries including Game Trails and Energy Beams, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | +| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (seven generated libraries including Ink and Wash, Game Trails and Energy Beams, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | @@ -489,7 +489,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Drawing tools | 7 | 86% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; Pencil: Alt key toggles to Smooth Tool (#2001); remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding | 7–12 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading, except next to RGB or Lab stops; dithered fills and Perceptual gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve are written as images in process colors), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | -| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (six generated in code including Game Trails and Energy Beams are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | +| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (seven generated in code including Ink and Wash, Game Trails and Energy Beams are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | @@ -541,7 +541,7 @@ since 2026-10-01. | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | | **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai` (and legacy version 7 `.ai` out, keeping layers, groups and type as text), EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | -| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries six (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) beside User Defined and Other Library… | +| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries seven (Art Ribbons, Ink and Wash, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) beside User Defined and Other Library… | | **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine | | **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA | | **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under native Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only), so Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland, where drops work (#1151; verified by unit tests and cross-compilation, not on a Wayland desktop); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending | @@ -845,7 +845,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Expand of symbol instances:** Object › Expand turns selected symbol instances (without effects, not in perspective, in groups or placed documents too) into their art as Break Link to Symbol places it, one level deep, as one undo step; the Expand dialog's Fill and Stroke reach that art (`object.expand`, `crates/engine/src/tests/expand_symbols.rs`; estimated, score unchanged). - **Reset Transformation** ([Transform symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/transform-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Reset Transformation and the Control bar's Reset, shown while symbol instances are selected, give the selected instances their symbol's own scale again, without rotation or shear, as one undo step (`symbol.instance.resetTransform`); an instance in perspective is left as it is. VectorCraft's own rules: each instance keeps the center of its bounding box in place, reflection is removed too, and the menu item and the button are enabled only while a selected instance would change. Tests: `tests::brushsym::reset_transformation_restores_the_symbols_scale_and_keeps_the_center`, `reset_transformation_keeps_the_center_of_art_away_from_the_origin`, `reset_transformation_ignores_a_stored_size_that_is_not_positive`, `reset_transformation_leaves_unchanged_instances_and_refuses_bad_ids`, `panels::symbols::tests::reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it`. - **Expand of dimensions:** Object › Expand, Expand Appearance, Create Outlines and Outline Stroke turn a dimension whose art they change into a plain group of that art, which stays as it is through later moves, scales and edits of what it measured; undo gives the live dimension back (`crates/engine/tests/it/dimensions.rs`; VectorCraft's own rule; estimated, score unchanged). - - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. + - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Ink and Wash, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Ink and Wash adds ink and wash brushes (the [help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html) lists an Ink brush library of strokes with varied thickness and texture; the names, shapes and opacities are VectorCraft's own): Tapered Ink, an art brush broad near its start that thins to a fine tip; Brush Pen, a calligraphic brush whose size follows the pen pressure; and Soft Wash and Pooling Wash, art brushes of translucent gray layers that take the stroke color as tints; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection, and on type Bullets and Numbering and Spelling › Auto Spell Check, checked while it is on), or the view commands on empty canvas. - A right-click over locked objects adds an Unlock submenu to the canvas context menu. The submenu lists up to 20 locked objects under the pointer, topmost first, by the name the Layers panel shows, and choosing one unlocks it (`layer.setProps`). A locked group is listed as one item. Objects on hidden, locked or template layers are not listed. - Native macOS save sheets offer a localised Format menu (#866), built from the request's existing format filters. It selects the suggested filename's format and updates the suffix when changed, including legacy `.drawcraft` names. `apps/vectorcraft/src/mac_save_format.rs` has filename regressions; an actual native SVG save, selection of PDF and cancellation without changing the saved document were verified. From b42a3d4869dbfe77042f376c2cb60c74a3168d97 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 23:33:02 -0800 Subject: [PATCH 05/92] Stroke: stroke.set refuses a custom arrowhead path that fills no area A {Custom: {name, path}} head is refused when its path fills no area under the non-zero rule: a single anchor, points on one line, or contours that cancel out. A bow tie and a thin bar are taken. The params text and docs/mcp.md state the rule. --- crates/engine/src/cmd/stroke.rs | 17 +++++++++++--- crates/engine/src/tests/arrowheads.rs | 33 +++++++++++++++++++++++++++ docs/mcp.md | 8 ++++--- 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 49b3128f61..40b2a7aa86 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -22,7 +22,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", + "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", has_doc, stroke_set ), @@ -234,9 +234,20 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { Some(Value::Object(o)) if o.contains_key("Custom") => { let raw = o.get("Custom").and_then(|c| c.get("path")).cloned().unwrap_or(Value::Null); let path: PathData = serde_json::from_value(raw).map_err(|e| bad(C, format!("{k}: bad custom arrowhead path: {e}")))?; - if path.subpaths.iter().all(|sp| sp.anchors.is_empty()) || !CustomArrowhead::fits(&path) { + // The region the outline fills (non-zero rule), down to slivers a millionth of a + // head unit thin. Measured only once `fits` has bounded the anchors. + let fills = |p: &PathData| { + let area = vectorcraft_pathops::unite_all_with(&[(p, vectorcraft_geom::FillRule::NonZero)], 1e-6); + area.subpaths.iter().map(|sp| sp.area().abs()).sum::() > 1e-12 + }; + if !CustomArrowhead::fits(&path) || !fills(&path) { let (most, far) = (CustomArrowhead::MAX_ANCHORS, CustomArrowhead::MAX_UNITS); - return Err(bad(C, format!("{k}: a custom arrowhead's path needs 1 to {most} anchors, each within {far} head units of its tip"))); + return Err(bad( + C, + format!( + "{k}: a custom arrowhead's path needs an area to fill and at most {most} anchors, each within {far} head units of its tip" + ), + )); } let head = p.get(k).cloned().unwrap_or(Value::Null); serde_json::from_value::(head).map(|a| Some(Some(a))).map_err(|e| bad(C, format!("{k}: bad custom arrowhead: {e}"))) diff --git a/crates/engine/src/tests/arrowheads.rs b/crates/engine/src/tests/arrowheads.rs index 1e5c1fe5a8..2fdbbebca9 100644 --- a/crates/engine/src/tests/arrowheads.rs +++ b/crates/engine/src/tests/arrowheads.rs @@ -194,7 +194,40 @@ fn the_arrowheads_file_is_made_opened_and_reloaded_when_saved() { let mut empty = serde_json::to_value(Arrowhead::Custom(c)).unwrap(); empty["Custom"]["path"] = json!({"subpaths": []}); assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": empty})).is_err()); + // One anchor, or anchors on one line, fill nothing. + for pts in [vec![Point::new(1.0, 1.0)], vec![Point::new(0.0, 0.0), Point::new(2.0, 0.0), Point::new(4.0, 0.0)]] { + let mut flat = + serde_json::to_value(Arrowhead::Custom(Arc::new(vectorcraft_doc::CustomArrowhead::new("Flat".into(), PathData::default())))).unwrap(); + flat["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::polyline(&pts, false))).unwrap(); + assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": flat})).is_err(), "{pts:?}"); + } + // A lone anchor draws no segment, whatever its handles. + let mut lone = vectorcraft_geom::Anchor::corner(Point::new(1.0, 0.0)); + (lone.h_in, lone.h_out) = (Point::new(0.0, 1.0), Point::new(2.0, -1.0)); + let mut handles = + serde_json::to_value(Arrowhead::Custom(Arc::new(vectorcraft_doc::CustomArrowhead::new("Lone".into(), PathData::default())))).unwrap(); + handles["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::new(vec![lone], true))).unwrap(); + assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": handles.clone()})).is_err(), "a lone anchor"); + // Two equal squares winding opposite ways cancel out. + let sq = [Point::new(0.0, -1.0), Point::new(2.0, -1.0), Point::new(2.0, 1.0), Point::new(0.0, 1.0)]; + let back: Vec = sq.iter().rev().copied().collect(); + let cancel = PathData::new(vec![SubPath::polyline(&sq, true), SubPath::polyline(&back, true)]); + handles["Custom"]["path"] = serde_json::to_value(cancel).unwrap(); + assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": handles})).is_err(), "opposite squares"); + // A bow tie's lobes wind opposite ways, so its signed area is 0, and it still fills both. + let tie = [Point::new(0.0, -1.0), Point::new(2.0, 1.0), Point::new(2.0, -1.0), Point::new(0.0, 1.0)]; + let mut bow = + serde_json::to_value(Arrowhead::Custom(Arc::new(vectorcraft_doc::CustomArrowhead::new("Bow".into(), PathData::default())))).unwrap(); + bow["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::polyline(&tie, true))).unwrap(); assert!(matches!(stroke(&s, id).end_arrow, Some(Arrowhead::Custom(h)) if h.name == "Square"), "the head stays as it was"); + s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": bow.clone()})).unwrap(); + assert!(matches!(stroke(&s, id).end_arrow, Some(Arrowhead::Custom(h)) if h.name == "Bow")); + // So does a very thin bar. + let bar = [Point::new(0.0, 0.0), Point::new(4.0, 0.0), Point::new(4.0, 0.001), Point::new(0.0, 0.001)]; + bow["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::polyline(&bar, true))).unwrap(); + s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": bow})).unwrap(); + let Some(Arrowhead::Custom(h)) = stroke(&s, id).end_arrow else { panic!("a custom head") }; + assert_eq!(h.path.anchor_count(), 4, "the thin bar"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/docs/mcp.md b/docs/mcp.md index 02f83c6cc5..aa6f7bcd69 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3968,9 +3968,11 @@ path, the `skipped` symbols and any read `error`. `stroke.set {endArrow: {"custom": name}}` puts a custom head on a stroke. The stroke keeps a copy of the head's art, so the document draws it, saves it and exports it to SVG and PDF the same way without the arrowheads file. -`document.inspect` reports such a head as `{"Custom": {name, path}}`, which `stroke.set` takes back unchanged. The -help page [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html) describes -defining custom arrowheads in a separate arrowheads file. +`document.inspect` reports such a head as `{"Custom": {name, path}}`, which `stroke.set` takes back unchanged. It +refuses a `path` that fills no area (non-zero rule; parts thinner than a millionth of a head unit count as none) or +has more than 4,096 anchors or a point more than 64 head units from the tip. The help page +[Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html) describes defining custom +arrowheads in a separate arrowheads file. ```json {"name":"run_command","arguments":{"command":"stroke.arrowheads.list","params":{}}} From 46ce6364c5de96c106fd03ada2261c1da50639aa Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 23:35:26 -0800 Subject: [PATCH 06/92] MCP: document.inspect and document.json leave out paths outside a confined client's read roots Under --automation-read-root, document.inspect gives `path` as null when the document's file is outside the read roots. document.json names a linked image or placed file outside them by its file name alone, without its relative path, size or date, in the layers, symbols, graph designs, patterns, the pattern being edited and opacity masks. It also names a data set's image value outside them by its file name, and leaves the Export for Screens folder outside them out of export_settings. The document is not changed, and unconfined callers see the same replies as before. --- crates/engine/src/cmd/edit.rs | 8 +++- crates/engine/src/cmd/links.rs | 46 ++++++++++++++++++- crates/engine/src/inspect.rs | 3 +- crates/engine/src/tests/placed_document.rs | 52 ++++++++++++++++++++++ docs/mcp.md | 5 +++ docs/target-app-parity.md | 1 + 6 files changed, 111 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 108b8593c9..f050684cd3 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -38,7 +38,7 @@ pub fn specs() -> Vec { has_doc, doc_move ), - cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect), + cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → path (the document's file; null when automation read roots are in force and the file is outside them), layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect), cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node), cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, within?: [x, y, width, height] (an object's geometric bounds inside this rectangle, edges included; document points as document.inspect's bounds; layers never match this or the size filters), intersects?: [x, y, width, height] (bounds touching or overlapping it), minWidth?, maxWidth?, minHeight?, maxHeight?: pt (the bounds' size), minThickness?, maxThickness?: pt (the smaller of the bounds' width and height), minArea?, maxArea?: pt² (the bounds' width × height), minAnchors?, maxAnchors?: anchor points of a path (a compound path's members together; other objects never match), select?: bool (also select the matches, leaving out hidden and locked ones, those inside another match, and a compound path's members unless the compound path is isolated; Select > Reselect repeats it), limit?: max matches listed (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total, selected (with select: the count selected)}; at least one filter, all of them matching", has_doc, doc_find), cmd!( @@ -50,7 +50,11 @@ pub fn specs() -> Vec { has_doc, doc_compare ), - cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))), + cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model (with automation read roots, a linked or placed file outside them is named by its file name alone, without its relative path, size or date, and so is a data set's image value outside them; a folder outside them is left out of export_settings)", has_doc, |s, _| { + let st = s.doc()?; + let d = super::links::with_hidden_paths_named(&st.doc, super::links::LinkBase::of(st).doc); + Ok(serde_json::to_value(&*d).unwrap_or(Value::Null)) + }), cmd!( "document.setUnits", "Units", diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index a134eb1c55..effa917d8e 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -28,7 +28,9 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_doc::links::{Align, Preserve, hash_bytes}; -use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions}; +use vectorcraft_doc::{ + Appearance, DataValue, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions, +}; use vectorcraft_geom::{Affine, PathData, Rect, shapes}; use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file}; @@ -1195,6 +1197,48 @@ pub(crate) fn with_found_paths(d: &mut Document, base: LinkBase) { }); } +/// `d`, a document saved at `doc_path`, as a reply to the caller asking now may show it: without +/// read roots in force, `d` itself; with them, a copy in which each link [`path_shown`] does not +/// allow names its file alone, without its relative path or the size and date kept of its file, +/// and each data set's image value it does not allow names its file alone too. The folder Export +/// for Screens last wrote to is left out of the export settings when it is outside the read roots. +/// Links in the layers, symbols, graph designs, patterns, the pattern being edited as it was +/// before and opacity masks are all checked. +pub(crate) fn with_hidden_paths_named<'a>(d: &'a Document, doc_path: Option<&str>) -> Cow<'a, Document> { + if crate::file_access::current().is_none() { + return Cow::Borrowed(d); + } + let mut d = d.clone(); + // The pattern being edited as it was before is checked as one more pattern, then put back. + let original = d.pattern_edit.as_mut().and_then(|e| e.original.take()); + let edited = original.is_some(); + d.patterns.extend(original); + d.update_links_where( + |l| !path_shown(&l.path, doc_path), + |l| { + let name = l.name().to_string(); + *l = LinkInfo { path: name, relative: None, modified: None, size: None, ..l.clone() }; + }, + ); + if edited && let (Some(def), Some(e)) = (d.patterns.pop(), d.pattern_edit.as_mut()) { + e.original = Some(def); + } + if d.export_settings.get("folder").and_then(Value::as_str).is_some_and(|f| crate::file_access::check_read(f).is_err()) { + d.export_settings.remove("folder"); + } + let hidden = |v: &DataValue| matches!(v, DataValue::Image(path) if !path_shown(path, doc_path)); + if d.variables.datasets.iter().any(|set| set.values.values().any(hidden)) { + for v in d.variables.datasets.iter_mut().flat_map(|set| set.values.values_mut()) { + if hidden(&*v) + && let DataValue::Image(path) = v + { + *path = LinkInfo::new(path.as_str()).name().to_string(); + } + } + } + Cow::Owned(d) +} + // ---------- commands ---------- fn check(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 9bad62a6c8..05cc8fe77e 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -287,7 +287,8 @@ fn document_base(s: &Session) -> Value { "title": st.title(), // The File Info title (`file.info`), where `title` comes from the file name once the document has one. "fileInfoTitle": d.title, - "path": st.path, + // Null for a confined caller when the file is outside its read roots. + "path": st.path.as_deref().filter(|p| crate::file_access::check_read(p).is_ok()), "dirty": st.is_dirty(), "revision": st.revision, "units": d.units.label(), diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index e7d3c7cd7a..3ec0310430 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -1084,6 +1084,58 @@ fn link_info_leaves_out_a_placed_documents_path_outside_the_read_roots() { assert_eq!((&i["link"], &i["path"]), (&json!(src), &json!(src)), "unconfined: {i}"); } +/// `document.inspect` and `document.json` for a confined caller: the document's file, a placed +/// document and a linked image outside the read roots are not named by path, and the document +/// keeps its links. +#[test] +fn document_replies_leave_out_paths_outside_the_read_roots() { + let away = Folder::new("doc-lrr-away"); + let home = Folder::new("doc-lrr-home"); + let src = away.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let pic = away.file("photo.png"); + write(&pic, &crate::tests_links::png(60, 30, BLUE)); + let mut s = session(); + let id = place(&mut s, &src); + let r = s.execute("file.place", &json!({"path": pic, "at": [50, 50]})).unwrap(); + assert_eq!(r["linked"], true, "{r}"); + s.execute("variable.define", &json!({"name": "Photo", "kind": "image"})).unwrap(); + s.execute("dataset.new", &json!({"name": "Row", "values": {"Photo": pic}})).unwrap(); + let image = r["ids"][0].as_u64().unwrap(); + let poster = away.file("poster.vectorcraft"); + save(&mut s, &poster); + // The folder Export for Screens wrote to, and a pattern of the linked image being edited: its + // definition before editing is kept beside the tile art. + let out = away.file("out"); + s.execute("document.exportForScreens", &json!({"folder": out, "formats": [{"format": "svg"}]})).unwrap(); + let pattern = s.execute("object.pattern.make", &json!({"ids": [image], "edit": false})).unwrap()["name"].clone(); + s.execute("object.pattern.edit", &json!({"name": pattern})).unwrap(); + assert!(s.doc().unwrap().doc.pattern_edit.as_ref().is_some_and(|e| e.original.is_some())); + let ask = |s: &mut Session, root: &std::path::Path, cmd: &str| { + let roots = crate::file_access::AutomationRoots::new(&[root], &[]).unwrap().unwrap(); + crate::file_access::confine(Some(&roots), || s.execute(cmd, &json!({}))).unwrap() + }; + let inspect = ask(&mut s, &home.0, "document.inspect"); + assert!(!inspect.to_string().contains("doc-lrr-away"), "{inspect}"); + assert!(inspect["path"].is_null() && inspect["uid"].is_u64(), "{inspect}"); + let model = ask(&mut s, &home.0, "document.json").to_string(); + assert!(!model.contains("doc-lrr-away"), "{model}"); + for name in ["logo.vectorcraft", "photo.png"] { + assert!(model.contains(&format!("\"path\":\"{name}\"")), "{name}: {model}"); + } + assert!(model.contains("{\"image\":\"photo.png\"}"), "{model}"); + // The document keeps its links, and a caller whose read roots hold the files sees every path. + assert_eq!(placed(&s, id).link.path, src); + for model in [ask(&mut s, &away.0, "document.json"), s.execute("document.json", &json!({})).unwrap()] { + let model = model.to_string(); + for path in [&src, &pic, &out] { + assert!(model.contains(&json!(path).to_string()), "{path}: {model}"); + } + } + assert_eq!(ask(&mut s, &away.0, "document.inspect")["path"], json!(poster)); + assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["path"], json!(poster)); +} + /// Save As reads its defaults from the catalog; using them must retain current editable links. #[test] fn native_save_catalog_defaults_keep_placed_documents_editable() { diff --git a/docs/mcp.md b/docs/mcp.md index aeed51157a..8a5a05b9be 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -158,6 +158,11 @@ the suite) keep the agent to the folders you give it: `null`; an empty folder and the scratch disk choices `Startup` and `None` stay. In `prefs.get {}` and `prefs.reset`, an export preset in `screenExportPresets` leaves out its `folder` when the folder is outside the read roots, and the preset stays. The preferences themselves are not changed. +- `document.inspect` (`inspect_document`) gives `path` as null when the document's file is outside the read roots. + `document.json` names a linked image or placed file outside them by its file name alone in the link's `path`, and + leaves out the link's `relative`, `size` and `modified`. It also names a data set's image value outside them by + its file name alone, and leaves out the `folder` of `export_settings` (the settings Export for Screens last used) + when that folder is outside them. The document itself is not changed. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an output folder and a scratch folder, without widening a root to their common parent. A folder given twice diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5e0cf03a3a..b5fdf7e26b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -760,6 +760,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`. - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). - Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`). + - Document replies under `--automation-read-root`: `document.inspect` gives `path` as null for a document file outside the read roots, and `document.json` names a linked image or placed file outside them by its file name alone, without its relative path, size or date, and a data set's image value outside them by its file name, and leaves an Export for Screens folder outside them out of `export_settings`; the document is not changed. `crates/engine/src/tests/placed_document.rs` (`document_replies_leave_out_paths_outside_the_read_roots`). - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. - Preferences commands under `--automation-read-root`: `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show folder preferences outside the read roots as `null` and leave out an export preset's folder outside them, and the preferences are not changed. `crates/engine/src/tests/prefs_roots.rs`. - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. From e6f330afd23abc78514265cd078ab1cd384bb803 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 23:59:18 -0800 Subject: [PATCH 07/92] Automation: ui.dialog.set cannot change the command a dialog runs ui.dialog.set took every field whose name starts with `__`, so a control request (or MCP's answer_dialog, which goes through it) could rewrite the `__command` and `__params` of a confirmation or the generic parameter dialog, a plug-in command dialog's `__command`, or the paste a swatch conflict waits to finish, after a person opened the dialog. The person's OK then ran something else. A `__` field is now settable only when it is a control of that kind of dialog: a section or tab shown, a link between two fields, a name or search being typed (for example Save PDF's `__section`, Transform Each's `__constrain`, the Preferences `__search`). Every other `__` field, held or not, is refused with "the `` dialog cannot set protected field ``". The request sets no field, and with `action: "confirm"` OK does not run. Flags a button sets together with other fields are refused too: the style dialogs' `__reset*`, Artboard Options' `__fit`, `__sizeChoice`, `__refx` and `__refy`, and Scribble's `__scribbleSetting`. Recolor's Merge takes a row listed twice in the selection once, and Randomize keeps a seed at u64::MAX instead of overflowing it. Docs: docs/control-protocol.md, docs/mcp.md, docs/target-app-parity.md. --- crates/ui-egui/src/control.rs | 5 + crates/ui-egui/src/control/tests.rs | 8 +- crates/ui-egui/src/dialogs.rs | 69 +++++- .../src/dialogs/brush_options/tests.rs | 10 +- crates/ui-egui/src/dialogs/recolor.rs | 27 ++- crates/ui-egui/src/placing/tests.rs | 3 +- crates/ui-egui/src/tests/automation.rs | 204 +++++++++++++++++- docs/control-protocol.md | 2 +- docs/mcp.md | 2 +- docs/target-app-parity.md | 1 + 10 files changed, 312 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 11edb6de6e..1c09f918f9 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -18,6 +18,8 @@ //! - `ui.set {brightness?, panel?, dockTab?, rulers?, outline?, …}` //! - `ui.dialog.set {field, value}` or `{fields: [{field, value}…]}` (all or none; setting a field //! the dialog does not read is an error that lists its fields: [`crate::dialogs::accepts_field`]; +//! a `__` field other than the controls of the dialog's kind is an error too, so a request cannot +//! change the command, targets or file the dialog's OK acts on: [`crate::dialogs::protected_field`]; //! `action: confirm | cancel` then confirms or cancels that dialog in the same request and replies //! `{dialog, result}`) / `ui.dialog.confirm` / `ui.dialog.cancel` //! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?, data?}` (`data: true`: the PNG @@ -603,6 +605,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques }; // Every field is checked before any is set, so a request sets all of them or none. if let Some((f, _)) = fields.iter().find(|(f, _)| !crate::dialogs::accepts_field(d, f)) { + if crate::dialogs::protected_field(d, f) { + return err(format!("the `{}` dialog cannot set protected field `{f}`", d.kind)); + } let names = crate::dialogs::field_names(d).join(", "); return err(format!("the `{}` dialog has no field `{f}`; its fields: {names}", d.kind)); } diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 1763fd679a..d441946622 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -767,7 +767,8 @@ fn save_and_close_apply_a_merge_on_another_thread_first() { /// `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`. +/// with a discard button, `preview` with a live preview and the spec's `inputs`. A `__` field that +/// is not one of the controls of the dialog's kind is refused. #[test] fn dialog_set_refuses_a_field_the_dialog_does_not_have() { let mut app = VectorcraftApp::new(Session::new(), Services::default()); @@ -776,8 +777,11 @@ fn dialog_set_refuses_a_field_the_dialog_does_not_have() { let r = input_request(&mut app, "ui.dialog.set", json!({"field": "discrad", "value": true})); assert_eq!(r["ok"], false, "{r}"); assert_eq!(r["error"], "the `saveChanges` dialog has no field `discrad`; its fields: discard, index, name, then, uid"); + assert_eq!(app.ui.dialog.as_ref().map(|d| Value::Object(d.fields.clone())), Some(fields.clone())); + let r = input_request(&mut app, "ui.dialog.set", json!({"field": "__note", "value": true})); + assert_eq!(r["error"], "the `saveChanges` dialog cannot set protected field `__note`", "{r}"); assert_eq!(app.ui.dialog.as_ref().map(|d| Value::Object(d.fields.clone())), Some(fields)); - for field in ["name", "discard", "__note"] { + for field in ["name", "discard"] { let r = input_request(&mut app, "ui.dialog.set", json!({"field": field, "value": true})); assert_eq!(r["ok"], true, "{field}: {r}"); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f9788d59a8..9274bbd40c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -481,13 +481,69 @@ fn spec(kind: &str) -> &'static DialogSpec { DialogKind::of(kind).map_or(&FALLBACK, DialogKind::spec) } -/// Whether `ui.dialog.set` can set `field` on `d`: a field it holds, a `__` field (the dialog's -/// own state), `discard` on a dialog with a discard button, `preview` on one with a live preview, -/// one of its spec's `inputs` for its kind, or one it `passes` on. +/// The `__` fields that `ui.dialog.set` can set on `d`: controls of its kind that the dialog holds +/// under a `__` name, each set as the control sets it. Every other `__` field of a dialog holds +/// what its OK acts on (the command and its fixed params, the targets, a file, the preset being +/// edited) or state the dialog keeps for itself, and `ui.dialog.set` refuses it +/// ([`protected_field`]), so a request cannot change what OK does after a person opened the dialog. +/// Flags that a button sets together with other fields are refused too: the style dialogs' +/// `__reset*` (Reset Panel also clears the section's fields, so scripts run +/// `ui.charStyleOptions.resetPanel` or `ui.paraStyleOptions.resetPanel`) and Artboard Options' +/// `__fit`, `__sizeChoice`, `__refx` and `__refy` (set with the size and position they record), and +/// Scribble's `__scribbleSetting` (the menu sets the preset's parameters with it). +fn hidden_controls(d: &Dialog) -> &'static [&'static str] { + match DialogKind::of(&d.kind) { + // Duotone's tone row, and the 3D effects' tab, gizmo, light and Show axis. + Some(DialogKind::Effect) => &["__gizmo", "__light", "__showAxis", "__tab", "__tone"], + // The preset name being typed and the format whose settings the gear shows. + Some(DialogKind::ExportForScreens) => &["__savePreset", "__settings"], + // Keywords typed but not added yet. + Some(DialogKind::FileInfo) => &["__keyword"], + // The preset name being typed. + Some(DialogKind::FlattenTransparency) => &["__presetName"], + // The Resolution menu's Other. + Some(DialogKind::RasterOptions | DialogKind::RasterFormatOptions | DialogKind::PsdOptions) => &["__otherPpi"], + // The category shown, the search text and the three settings kept outside the preferences. + Some(DialogKind::Preferences) => &["__category", "__search", "__smartGuides", "__snapToGrid", "__taskBar"], + // The section shown, the sheet the preview shows and the preset name being typed. + Some(DialogKind::Print | DialogKind::PrintPreset) => &["__savePresetAs", "__section", "__sheet"], + // The section shown, All or a range of artboards, the two password checkboxes and the + // preset name being typed. + Some(DialogKind::SavePdf | DialogKind::PdfPreset) => { + &["__allArtboards", "__requireOpenPassword", "__restrictPermissions", "__savePresetAs", "__section"] + } + // The selected rows and Randomize's seed. + Some(DialogKind::Recolor) => &["__seed", "__sel"], + // The view, zoom, download speed, selected color table entry and the preset name being typed. + Some(DialogKind::SaveForWeb) => &["__color", "__kbps", "__presetName", "__view", "__zoom"], + // The link that keeps width and height in proportion. + Some(DialogKind::Shape | DialogKind::TransformEach) => &["__constrain"], + // The attention filter, the Keys section, the search text and the key being recorded. + Some(DialogKind::ShortcutImport) => &["__attention", "__keysOpen", "__query", "__recording"], + // Set Size of Each's Width and Height checkboxes. + Some(DialogKind::ArrangeTools) if d.kind == crate::addons::arrange::SIZE => &["__height", "__width"], + // The search field. + Some(DialogKind::SelectByArtboards | DialogKind::SelectByLinkStatus) => &["__search"], + _ => &[], + } +} + +/// Whether `field` is a `__` field of `d` that `ui.dialog.set` refuses: any `__` field, held or +/// not, except the controls of `d`'s kind ([`hidden_controls`]). +pub fn protected_field(d: &Dialog, field: &str) -> bool { + field.starts_with("__") && !hidden_controls(d).contains(&field) +} + +/// Whether `ui.dialog.set` can set `field` on `d`: a `__` field only when it is one of the controls +/// of `d`'s kind ([`protected_field`]); otherwise a field it holds, `discard` on a dialog with a +/// discard button, `preview` on one with a live preview, one of its spec's `inputs` for its kind, +/// or one it `passes` on. pub fn accepts_field(d: &Dialog, field: &str) -> bool { + if field.starts_with("__") { + return !protected_field(d, field); + } let spec = spec(&d.kind); d.fields.contains_key(field) - || field.starts_with("__") || (field == "discard" && spec.discard.is_some()) || (field == "preview" && spec.preview) || (spec.inputs)(&d.kind).contains(&field) @@ -520,11 +576,12 @@ pub fn set_field(d: &mut Dialog, field: String, value: Value) { } } -/// The fields `ui.dialog.set` can set on `d`, sorted, without the `__` ones: those it holds, -/// `discard`, `preview` and the spec's `inputs` for its kind. +/// The fields `ui.dialog.set` can set on `d`, sorted: those it holds other than its `__` ones, the +/// `__` controls of its kind, `discard`, `preview` and the spec's `inputs` for its kind. pub fn field_names(d: &Dialog) -> Vec { let spec = spec(&d.kind); let mut names: Vec = d.fields.keys().filter(|k| !k.starts_with("__")).cloned().collect(); + names.extend(hidden_controls(d).iter().map(|f| f.to_string())); names.extend(spec.discard.map(|_| "discard".to_string())); names.extend(spec.preview.then(|| "preview".to_string())); names.extend((spec.inputs)(&d.kind).iter().map(|i| i.to_string())); diff --git a/crates/ui-egui/src/dialogs/brush_options/tests.rs b/crates/ui-egui/src/dialogs/brush_options/tests.rs index 3ae028bb05..f2fcc88981 100644 --- a/crates/ui-egui/src/dialogs/brush_options/tests.rs +++ b/crates/ui-egui/src/dialogs/brush_options/tests.rs @@ -436,13 +436,17 @@ fn each_tips_sample_paints_the_brush_with_its_method() { assert!(tints != hue && tints != shades && none != shades, "four different samples"); } -/// `ui.dialog.set` writes any field, `__def` included. With a brush definition that is not -/// an object, Brush Options and Colorization Tips still draw. +/// With a brush definition that is not an object, Brush Options and Colorization Tips still draw. +/// `ui.dialog.set` refuses `__def`, which holds the brush OK saves. #[test] fn a_brush_definition_that_is_not_an_object_still_draws() { let (mut app, ctx) = (app(), ctx()); app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap(); - crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"field": "__def", "value": 5})); + let (req, _) = crate::control::ControlRequest::new("ui.dialog.set", json!({"field": "__def", "value": 5})); + let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &ctx, &req) else { panic!("not done") }; + assert_eq!(r["error"], "the `brushOptions` dialog cannot set protected field `__def`", "{r}"); + assert_ne!(app.ui.dialog.as_ref().unwrap().fields.get("__def"), Some(&json!(5)), "the request is refused"); + set(&mut app, json!({"__def": 5})); crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"field": "tips", "value": true})); let text = shown(&mut app); assert!(text.contains("Scatter Brush Options") && text.contains("Colorization Tips"), "{text}"); diff --git a/crates/ui-egui/src/dialogs/recolor.rs b/crates/ui-egui/src/dialogs/recolor.rs index 6f696f4302..784a19007b 100644 --- a/crates/ui-egui/src/dialogs/recolor.rs +++ b/crates/ui-egui/src/dialogs/recolor.rs @@ -262,6 +262,11 @@ fn excluded(row: &Value) -> bool { row["exclude"].as_bool().unwrap_or(false) } +/// The seed for the next Randomize: one past the last, held at `u64::MAX`. +fn next_seed(d: &Dialog) -> u64 { + (d.f64("__seed", 0.0) as u64).saturating_add(1) +} + fn selection(d: &Dialog) -> Vec { d.fields.get("__sel").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()).unwrap_or_default() } @@ -546,7 +551,7 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut [("arrow-left-right", tl!("Randomly change color order"), false), ("sparkles", tl!("Randomly change saturation and brightness"), true)] { if widgets::icon_button_enabled(ui, icon, tip, false, rows.len() > 1 || sb, 24.0).clicked() { - let seed = d.f64("__seed", 0.0) as u64 + 1; + let seed = next_seed(d); d.fields.insert("__seed".into(), json!(seed)); let p = json!({"map": rows, "order": !sb, "saturationBrightness": sb, "seed": seed}); match app.session.execute("recolor.randomize", &p) { @@ -897,6 +902,7 @@ fn sliders(app: &VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut [Value] fn merge(rows: &mut Vec, sel: &[usize]) { let mut sel: Vec = sel.iter().copied().filter(|i| *i < rows.len()).collect(); sel.sort_unstable(); + sel.dedup(); let Some((&first, rest)) = sel.split_first() else { return }; let mut moved = vec![]; for &i in rest.iter().rev() { @@ -1208,6 +1214,25 @@ mod tests { assert_eq!(r[2]["to"], "rgb 0 0 128"); } + /// A selection that lists a row twice merges it once, as `ui.dialog.set` can store one. + #[test] + fn merge_takes_a_repeated_row_once() { + let mut r = rows(); + merge(&mut r, &[0, 2, 2]); + assert_eq!(r.len(), 2); + assert_eq!(r[0]["from"], json!(["rgb 255 0 0", "rgb 0 255 0"])); + merge(&mut r, &[1, 1]); + assert_eq!(r.len(), 2); + } + + /// Randomize after a seed past `u64::MAX` keeps the largest seed. + #[test] + fn next_seed_holds_at_the_largest() { + assert_eq!(next_seed(&Dialog::new("recolor", json!({}))), 1); + assert_eq!(next_seed(&Dialog::new("recolor", json!({"__seed": 4}))), 5); + assert_eq!(next_seed(&Dialog::new("recolor", json!({"__seed": 1e30}))), u64::MAX); + } + #[test] fn linked_handles_move_together() { let mut r = rows(); diff --git a/crates/ui-egui/src/placing/tests.rs b/crates/ui-egui/src/placing/tests.rs index 96492a6512..9afe94b9ee 100644 --- a/crates/ui-egui/src/placing/tests.rs +++ b/crates/ui-egui/src/placing/tests.rs @@ -587,7 +587,8 @@ fn a_deferred_swatch_paste_rejects_a_changed_token_or_closed_document() { &crate::control::ControlRequest::new("ui.dialog.set", json!({"field": "__deferredPaste", "value": 999999})).0, ); let crate::control::Outcome::Done(result) = result else { panic!("expected dialog result") }; - assert_eq!(result["ok"], true, "{result}"); + assert_eq!(result["error"], "the `swatchConflict` dialog cannot set protected field `__deferredPaste`", "{result}"); + app.ui.dialog.as_mut().unwrap().fields.insert("__deferredPaste".into(), json!(999999)); } let error = crate::dialogs::confirm(&mut app).unwrap_err(); assert!( diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index df2fe29912..d2d0a8c4b6 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -10,6 +10,7 @@ use vectorcraft_engine::file_access::AutomationRoots; use crate::background::SharedWriteFn; use crate::control::ControlRequest; +use crate::state::Dialog; use crate::{Services, VectorcraftApp}; /// A folder holding `in` (the read root, with `a.svg`), `out` (the write root) and `outside` @@ -658,12 +659,12 @@ fn automation_cannot_approve_a_plugin_install() { assert!(app.ui.dialog.is_some(), "the refused answer leaves it open"); assert!(vectorcraft_plugins::registry::get(id).is_none()); - // What the dialog installs isn't in its fields. + // What the dialog installs isn't in its fields, and its `__` fields can't be set. let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__params", "value": {"dataBase64": "AAAA"}})); - assert!(r.get("error").is_none(), "{r}"); + refused(&r, "cannot set protected field `__params`"); // What MCP's `answer_dialog` sends: `ui.dialog.set {fields}`, then `ui.dialog.confirm`. let r = request(&mut app, &tx, "ui.dialog.set", json!({"fields": [{"field": "__params", "value": {"dataBase64": "AAAA"}}]})); - assert!(r.get("error").is_none(), "{r}"); + refused(&r, "cannot set protected field `__params`"); let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); refused(&r, ASKED); assert!(app.ui.dialog.is_some() && vectorcraft_plugins::registry::get(id).is_none()); @@ -691,6 +692,200 @@ fn automation_cannot_approve_a_plugin_install() { app.run("plugin.remove", json!({"id": id})).unwrap(); } +/// An app with a control channel and a new document. +fn controlled() -> (VectorcraftApp, std::sync::mpsc::Sender) { + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + (app, tx) +} + +/// The fields of the open dialog. +fn dialog_fields(app: &VectorcraftApp) -> Option> { + app.ui.dialog.as_ref().map(|d| d.fields.clone()) +} + +/// The objects on the document's first layer. +fn objects(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len()) +} + +/// The left edge and width of the first object on the document's first layer. +fn first_object(app: &VectorcraftApp) -> (f64, f64) { + let b = app.session.doc().unwrap().doc.layers[0].children().unwrap()[0].geometric_bounds().unwrap(); + (b.x0, b.width()) +} + +/// Each of `sets` sent as `ui.dialog.set` is refused with the protected-field error of the +/// `kind` dialog, sets no field, and runs nothing. +fn refused_sets(app: &mut VectorcraftApp, tx: &std::sync::mpsc::Sender, kind: &str, sets: &[Value]) { + for set in sets { + let before = dialog_fields(app); + let r = request(app, tx, "ui.dialog.set", set.clone()); + let says = format!("the `{kind}` dialog cannot set protected field `__"); + assert!(r["error"].as_str().is_some_and(|e| e.starts_with(&says)), "{set}: {r}"); + assert_eq!(dialog_fields(app), before, "{set}: no field changes and the dialog stays open"); + assert_eq!(objects(app), 0, "{set}: nothing ran"); + } +} + +/// A confirmation a person opened runs the command it was opened with: `ui.dialog.set` can't +/// change its `__command` or `__params`, alone or in a list with other fields and `action: +/// "confirm"`, and OK then runs the command it asked about. +#[test] +fn dialog_set_cannot_redirect_person_opened_confirm() { + let (mut app, tx) = controlled(); + let rect = json!({"x": 10, "y": 10, "width": 20, "height": 20}); + crate::dialogs::confirm::ask(&mut app, "Add a rectangle?", "", "shape.rectangle", rect); + let other = json!({"x": 0, "y": 0, "width": 90, "height": 90}); + refused_sets( + &mut app, + &tx, + crate::dialogs::confirm::KIND, + &[ + json!({"field": "__command", "value": "file.close"}), + json!({"field": "__params", "value": other}), + json!({"fields": [{"field": "detail", "value": "Sure?"}, {"field": "__command", "value": "file.close"}], "action": "confirm"}), + json!({"fields": [{"field": "__params", "value": other}, {"field": "detail", "value": "Sure?"}], "action": "confirm"}), + ], + ); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"fields": [{"field": "detail", "value": "Sure?"}], "action": "confirm"})); + assert_eq!(r["ok"], true, "{r}"); + assert!(app.ui.dialog.is_none() && objects(&app) == 1, "OK ran the command it asked about"); + assert_eq!(first_object(&app), (10.0, 20.0)); +} + +/// The generic parameter dialog runs its command with its fixed parameters: `ui.dialog.set` can +/// change the parameters it shows, not `__command`, `__params` or `__label`. +#[test] +fn dialog_set_cannot_redirect_parameter_dialog() { + let (mut app, tx) = controlled(); + let fields = json!({"__command": "shape.rectangle", "__label": "Rectangle", "__params": {"x": 10, "y": 10}, "width": 20, "height": 20}); + app.ui.dialog = Some(Dialog::new("command", fields)); + refused_sets( + &mut app, + &tx, + "command", + &[ + json!({"field": "__command", "value": "file.close"}), + json!({"field": "__params", "value": {"x": 500, "y": 500}}), + json!({"field": "__label", "value": "Close"}), + json!({"fields": [{"field": "width", "value": 30}, {"field": "__command", "value": "file.close"}], "action": "confirm"}), + ], + ); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "width", "value": 30, "action": "confirm"})); + assert_eq!(r["ok"], true, "{r}"); + assert!(app.ui.dialog.is_none() && objects(&app) == 1, "OK ran the dialog's command"); + assert_eq!(first_object(&app), (10.0, 30.0), "with its fixed x and the width set"); +} + +/// A plug-in command's dialog runs that command of that plug-in: `ui.dialog.set` can't change its +/// `__command` or `__plugin`. +#[test] +fn dialog_set_cannot_redirect_plugin_command() { + let (mut app, tx) = controlled(); + let fields = json!({"__plugin": "test.ui.redirect", "__command": "plugin.test.ui.redirect.a", "__label": "A", "amount": 1}); + app.ui.dialog = Some(Dialog::new(crate::dialogs::plugin::KIND, fields)); + refused_sets( + &mut app, + &tx, + crate::dialogs::plugin::KIND, + &[ + json!({"field": "__command", "value": "plugin.test.ui.redirect.b"}), + json!({"field": "__plugin", "value": "test.ui.other"}), + json!({"fields": [{"field": "amount", "value": 2}, {"field": "__command", "value": ""}], "action": "confirm"}), + ], + ); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "amount", "value": 2})); + assert_eq!(r["ok"], true, "{r}"); +} + +/// A `__` field the dialog does not hold is refused as a protected field too, and so is a control +/// of another kind of dialog; the error for an unknown field lists the dialog's own controls only. +#[test] +fn dialog_set_rejects_unknown_hidden_field() { + let (mut app, tx) = controlled(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 20})).unwrap(); + app.run("select.all", json!({})).unwrap(); + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + for field in ["__anything", "__section", "__ratio", "__constrainApplied"] { + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": field, "value": true})); + assert_eq!(r["error"], format!("the `transformEach` dialog cannot set protected field `{field}`"), "{r}"); + assert!(!dialog_fields(&app).unwrap().contains_key("__anything")); + } + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "nosuch", "value": true})); + let error = r["error"].as_str().unwrap_or_default(); + assert!(error.contains("has no field `nosuch`") && error.contains("__constrain"), "{r}"); + assert!(!error.contains("__ratio") && !error.contains("__section"), "{r}"); + + // Artboard Options' Fit, size preset and reference point flags are set by their buttons + // together with the size and position they record. + app.ui.dialog = Some(Dialog::new("artboardOptions", json!({"width": 60, "height": 20}))); + for (field, value) in [("__fit", json!(true)), ("__sizeChoice", json!({"landscape": true})), ("__refx", json!(2)), ("__refy", json!(2))] { + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": field, "value": value})); + assert_eq!(r["error"], format!("the `artboardOptions` dialog cannot set protected field `{field}`"), "{r}"); + assert!(!dialog_fields(&app).unwrap().contains_key(field)); + } + // Scribble's Settings menu sets the preset's parameters along with its name. + app.ui.dialog = Some(Dialog::new("effect", json!({"__effect": "scribble", "angle": 30}))); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__scribbleSetting", "value": "sharp"})); + assert_eq!(r["error"], "the `effect` dialog cannot set protected field `__scribbleSetting`", "{r}"); +} + +/// The dialog controls held under a `__` name are still set: Transform Each's link, the Save PDF +/// section and its All artboards checkbox, and the Preferences search. +#[test] +fn dialog_set_allows_hidden_controls() { + let (mut app, tx) = controlled(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 20})).unwrap(); + app.run("select.all", json!({})).unwrap(); + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__constrain", "value": true})); + assert_eq!(r["ok"], true, "{r}"); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.bool("__constrain"))); + app.ui.dialog = None; + + app.run("ui.savePdfDialog", json!({})).unwrap(); + let set = json!({"fields": [{"field": "__section", "value": "Security"}, {"field": "__allArtboards", "value": false}]}); + let r = request(&mut app, &tx, "ui.dialog.set", set); + assert_eq!(r["ok"], true, "{r}"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("__section"), d.bool("__allArtboards")), ("Security".to_string(), false)); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__save", "value": "file.saveCopy"})); + assert_eq!(r["error"], "the `savePdf` dialog cannot set protected field `__save`", "{r}"); + app.ui.dialog = None; + + // The raster options dialogs share the Resolution menu's Other, PSD Options included. + for kind in ["pngOptions", "tiffOptions", "psdOptions"] { + app.ui.dialog = Some(Dialog::new(kind, json!({"ppi": 72}))); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__otherPpi", "value": true})); + assert_eq!(r["ok"], true, "{kind}: {r}"); + } + app.ui.dialog = None; + + // A style section's reset flag alone would keep the section's values: scripts run the Reset + // Panel commands, which clear them. Which of the two styles a new style is stays fixed. + for (kind, para, flag) in [ + ("charStyleOptions", false, "__resetColor"), + ("paraStyleOptions", true, "__resetHyphenation"), + ("newTextStyle", false, "__resetColor"), + ("newTextStyle", true, "__resetHyphenation"), + ] { + app.ui.dialog = Some(Dialog::new(kind, json!({"name": "A", "__para": para}))); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": flag, "value": true})); + assert_eq!(r["error"], format!("the `{kind}` dialog cannot set protected field `{flag}`"), "{r}"); + assert!(!app.ui.dialog.as_ref().is_some_and(|d| d.bool(flag)), "{kind}"); + } + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__para", "value": false})); + assert_eq!(r["error"], "the `newTextStyle` dialog cannot set protected field `__para`", "{r}"); + app.ui.dialog = None; + + crate::prefs_dialog::open(&mut app, None); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__search", "value": "grid"})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("__search")), Some("grid".to_string())); +} + /// The refusal of an OK on an install question automation asked. const ASKED: &str = "Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself."; @@ -934,7 +1129,8 @@ fn automation_cannot_approve_replacing_a_plugin() { assert_eq!(r, json!({"pending": crate::dialogs::plugin::INSTALL_KIND})); let evil = vectorcraft_format::base64_encode(&plain(r#"{"objects":[{"name":"x"}]}"#)); let r = request(&mut app, &tx, "ui.dialog.set", json!({"fields": [{"field": "__params", "value": {"dataBase64": evil, "replace": true}}]})); - assert!(r.get("error").is_none(), "{r}"); + let protected = format!("the `{}` dialog cannot set protected field `__params`", crate::dialogs::plugin::INSTALL_KIND); + assert_eq!(r["error"], protected, "{r}"); super::plugins::show_approval(&mut app); let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); assert!(r["error"].as_str().is_some_and(|e| e.contains("Only the person at the keyboard")), "{r}"); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 640f1e805a..0fdfbc8231 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -102,7 +102,7 @@ the two can still win that race. | `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 | | `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls, Cmd-wheel (Ctrl on Windows and Linux) scrolls sideways and Alt-wheel (Option on the Mac) zooms about the pointer (with the `ctrlWheelScrollsSideways` preference off, Cmd-wheel zooms too); with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it. Over the canvas, the wheel preferences in `docs/mcp.md` change what these keys do | | `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?, imageTracePreview?}` | `outline`: View › Outline for the active document only (each document has its own, a document opens in Preview, and `ui.inspect` shows it as `view.outline`; an error without a document). `imageTracePreview`: the Image Trace panel's Preview (`ui.inspect` shows it as `ui.image_trace_preview`) | -| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value, action?}` or `{fields: [{field, value}…], action?}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`), `__` fields, `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes. `action: "confirm"` or `"cancel"` on `ui.dialog.set` then confirms or cancels the same dialog in the same request, as `ui.dialog.confirm` and `ui.dialog.cancel` do, and the reply is `{dialog, result}`: the dialog after the fields were set and the reply to confirm (`null` for cancel); any other `action` is an error and no field changes | +| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value, action?}` or `{fields: [{field, value}…], action?}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`) other than its `__` fields, the `__` fields that are controls of the dialog (such as `__section` and `__allArtboards` in Save PDF, `__constrain` in Transform Each and the shape dialogs, and `__search` and `__category` in Preferences), `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was. Any other `__` field, held or not, holds what the dialog's OK acts on (such as the `__command` and `__params` of a `confirm` or `command` dialog) or the dialog's own state: setting it is the error "the `` dialog cannot set protected field ``", and the dialog stays as it was. `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes. `action: "confirm"` or `"cancel"` on `ui.dialog.set` then confirms or cancels the same dialog in the same request, as `ui.dialog.confirm` and `ui.dialog.cancel` do, and the reply is `{dialog, result}`: the dialog after the fields were set and the reply to confirm (`null` for cancel); any other `action` is an error and no field changes | | `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. The inline PNG is capped at 64 MiB: with a `path` the file is still written and `pngBase64` is omitted when the image is larger, while a path-less `data: true` request fails if either the raw RGBA image or the encoded PNG exceeds the cap. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error | | `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | diff --git a/docs/mcp.md b/docs/mcp.md index aeed51157a..24d264cd38 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -509,7 +509,7 @@ applied colors still join the list while it is hidden. | `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. | | `press_key` | `{key, mods?}` | Remote: a real key event. Headless: runs the command or tool bound to that shortcut, or sends the key to the busy tool (digits too: `5` while dragging with the Perspective Selection tool; `e` while dragging a Selection or Group Selection marquee; `c` and `f` while dragging out an arc; `f`, `v`, `x` and `c` while dragging out a rectangular or polar grid). | | `type_text` | `{text}` | Remote only. | -| `answer_dialog` | `{set?: [{field, value?}], action?}` | Remote only. Sets fields of the app's open dialog in the order given with one `ui.dialog.set {fields, action?}` (a missing `value` is `null`). The app stores all of them or none, then answers that same dialog with `action` in the same request: `confirm` (OK) or `cancel`. Another connection cannot open a dialog between the two steps. When the app refuses the `ui.dialog.set`, for example because no dialog is open, the call is an error and the dialog is not confirmed or canceled. `action` alone is sent as `ui.dialog.confirm` or `ui.dialog.cancel`. Give `set`, `action` or both; `set` holds at most 256 fields. `inspect_ui` shows the open dialog and its fields as `ui.dialog`. Returns `{dialog, result}`: the dialog after the fields were set (or `null`) and the reply to `confirm` or `cancel` (or `null`). After `invoke_menu` `app.quit`, `file.close` or `file.closeAll` replies `{"pending": "saveChanges"}`, `{action: "confirm"}` saves, `{set: [{field: "discard", value: true}], action: "confirm"}` closes without saving and `{action: "cancel"}` cancels the close or quit. | +| `answer_dialog` | `{set?: [{field, value?}], action?}` | Remote only. Sets fields of the app's open dialog in the order given with one `ui.dialog.set {fields, action?}` (a missing `value` is `null`). The app stores all of them or none, then answers that same dialog with `action` in the same request: `confirm` (OK) or `cancel`. Another connection cannot open a dialog between the two steps. When the app refuses the `ui.dialog.set`, for example because no dialog is open, the call is an error and the dialog is not confirmed or canceled. `set` cannot change a `__` field other than the dialog's controls held under a `__` name (such as Save PDF's `__section` or the Preferences `__search`): the `__command` and `__params` of a `confirm` or `command` dialog, and every other `__` field that holds what OK acts on, are refused with "the `` dialog cannot set protected field ``", so OK runs what the dialog was opened for. `action` alone is sent as `ui.dialog.confirm` or `ui.dialog.cancel`. Give `set`, `action` or both; `set` holds at most 256 fields. `inspect_ui` shows the open dialog and its fields as `ui.dialog`. Returns `{dialog, result}`: the dialog after the fields were set (or `null`) and the reply to `confirm` or `cancel` (or `null`). After `invoke_menu` `app.quit`, `file.close` or `file.closeAll` replies `{"pending": "saveChanges"}`, `{action: "confirm"}` saves, `{set: [{field: "discard", value: true}], action: "confirm"}` closes without saving and `{action: "cancel"}` cancels the close or quit. | | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. UI commands such as `view.*` and `window.*` are in `command_list` with `ui: true`; running them needs the desktop app (`--connect`). | | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | | `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5e0cf03a3a..660051393d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1703,6 +1703,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **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 (a CMYK image's through the working CMYK profile; never a linked file's), and symbol instances with their symbol and transform. The objects come in paint order from the bottom, and an image that reaches the decoder counts toward the 16 MiB of pixels even when decoding fails. 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, a path or compound path that doesn't fit fails it too, and so do more than 100,000 of these objects. 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, except that compound paths that clip a clip group and paths inside a group that clips one are left out for every filter. 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 grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module that lists permissions and has other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A module with a missing or empty permissions list needs no grant; without the list, it gets its kind's implied permissions. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in approval needs a person (G17):** a plug-in's permissions and heavy budget are approved in a dialog that only a person can confirm: the control channel, MCP and injected input are refused, and `plugin.install` takes `grant` or `allowHeavy` only with that approval. `vectorcraft-cli mcp` and `run` refuse them unless started with `--allow-plugin-grants`. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/automation.rs`, `apps/vectorcraft-cli/tests/it/cli.rs`. Scores and hours unchanged. +- **A dialog's OK runs what it was opened for:** `ui.dialog.set`, and MCP's `answer_dialog` through it, can set a `__` field only when it is a control of that kind of dialog (a section or tab shown, a link between two fields, a name or search being typed). Every other `__` field, such as the `__command` and `__params` of a confirmation or the generic parameter dialog, a plug-in command dialog's `__command` and `__plugin`, or the paste a swatch conflict waits to finish, is refused with "the `` dialog cannot set protected field ``"; the request sets no field, and with `action: "confirm"` OK does not run. Regressions: `crates/ui-egui/src/tests/automation.rs`, `crates/ui-egui/src/control/tests.rs`. Scores and hours unchanged. - **Only an approval records a plug-in grant (G17):** `vectorcraft_plugins::registry::grant` takes a `GrantToken`, which the engine makes after an approval and when it loads `grants.json`, and `registry::install`, `install_bytes` and folder loads refuse a module that lists permissions no recorded grant gives this very module (`Error::NeedsApproval`), leaving the plug-in under its id and the revision as they were. A module without `permissions`, or with an empty list, installs as before. `docs/plugins.md` says what the approval does not protect against (native code linked into the app, another program running as the same user, the unsigned `grants.json`). Regressions: `crates/plugins/src/registry.rs`, `crates/plugins/tests/grant_sites.rs`, `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in approval waits before OK (G17):** the approval dialog's OK stays disabled until its question has been on screen in the focused window for half a second, VectorCraft's own value; a click, Enter, Space, or a press begun before then installs nothing, and the dialog stays open. The wait starts over for every new question, even one that reads the same, and when the window gets the focus back, so a question swapped in just before a click is not approved by it. Cancel works at once. Regressions: `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **An install automation asked for needs the person to start it (G17):** an approval question that a control request, MCP, injected input or a file dialog one of them opened leads to keeps OK disabled and refuses every confirm, a person's click included; it says to choose Object › Plug-ins › Install Plug-in…, and `ui.installPlugin` answers `approval: "personMustStartInstall"`. The person's own Install Plug-in…, File › Open or drop inspects the module again and asks a new question. Automation's `replace: true` no longer skips the question about taking another module's place, and that question now opens in the approval dialog for everyone. VectorCraft's own rule. Regressions: `crates/ui-egui/src/tests/automation.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. From bd82a598dd6863740b6ffe79207b5f283b70328c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 00:07:35 -0800 Subject: [PATCH 08/92] Blend: on-canvas widgets set a selected blend's color shift and easing With one whole blend selected, the Selection and Blend tools show two widgets at the halfway point of its spine. Dragging the upper widget along the spine sets the color shift (-100 to 100), and dragging the lower one sets the easing strength (0 to 100). The lower widget shows only when the steps follow a spine (a replaced spine or Align to Path), the rule the step placement uses; a blend made without a spine shows neither. When the boxes of three or more other visible objects hold a widget's spot, the widget moves out in 16 px steps, up to 144 px, and its line to the halfway point grows with it. While a widget is held, the arrow keys step its value by 1 (Shift: by 10), and Escape or a switch to another tool cancels the drag. A drag is one undo step; a click or a drag that ends where it started records none. - object.blend.setEasing sets easingType, ramp and colorShift on one blend, refuses a hidden or locked blend and easing on a blend without a spine, and records no undo step when nothing changes. - BlendSpec::eases_along_spine holds the rule the step placement and the widget share. - While a tool is busy, an arrow it claims arrives with the press's Shift and Alt; before, it always arrived plain. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/doc/src/blend.rs | 2 +- crates/doc/src/live/blend_spec.rs | 6 + crates/engine/src/cmd/live/blend.rs | 33 ++ crates/engine/src/cmd/live/commands.rs | 11 +- crates/engine/src/cmd/plugin_call/reviewed.rs | 1 + crates/engine/src/tests/blendopts.rs | 131 +++++ crates/tools/src/blendwidget.rs | 536 ++++++++++++++++++ crates/tools/src/lib.rs | 1 + crates/tools/src/meshblend.rs | 59 +- crates/tools/src/select.rs | 46 +- crates/ui-egui/src/canvas.rs | 5 +- crates/ui-egui/src/canvas/overlays.rs | 20 + crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/shortcuts.rs | 14 +- crates/ui-egui/src/shortcuts/tests.rs | 32 ++ docs/mcp.md | 17 + docs/target-app-parity.md | 2 + 43 files changed, 930 insertions(+), 13 deletions(-) create mode 100644 crates/tools/src/blendwidget.rs diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 05968ced7e..c9628016a5 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1421,7 +1421,7 @@ pub fn blend_expand(keys: &[Arc], spec: &BlendSpec) -> Vec { // Even position of the step, then where the easing puts its shape and its colours. let u = j as f64 / (n + 1) as f64; // Easing needs a spine: a blend made without one keeps its steps evenly spaced. - let eased = rail.is_some() && !spec.no_spine; + let eased = rail.is_some() && spec.eases_along_spine(); let (t, tc) = (if eased { spec.ease.apply(u) } else { u }, spec.color_position(u)); let mut s = lerp.at_eased(t, tc); if let Some(r) = &rail { diff --git a/crates/doc/src/live/blend_spec.rs b/crates/doc/src/live/blend_spec.rs index 56405e0d18..047e1ad7b5 100644 --- a/crates/doc/src/live/blend_spec.rs +++ b/crates/doc/src/live/blend_spec.rs @@ -119,6 +119,12 @@ impl BlendSpec { pub fn start(&self, i: usize) -> Option { self.starts.get(i).copied().flatten().map(|a| a as usize) } + + /// Whether [`Self::ease`] moves the steps: the blend has a spine ([`Self::no_spine`] is off) + /// and a replaced spine or Align to Path, the cases where the steps are placed along it. + pub fn eases_along_spine(&self) -> bool { + !self.no_spine && (self.spine.is_some() || self.orientation == BlendOrientation::AlignToPath) + } } /// The Blend panel's Create Spine setting: whether a new blend gets a spine. diff --git a/crates/engine/src/cmd/live/blend.rs b/crates/engine/src/cmd/live/blend.rs index 609936ecf7..63ae4a3fb9 100644 --- a/crates/engine/src/cmd/live/blend.rs +++ b/crates/engine/src/cmd/live/blend.rs @@ -385,6 +385,39 @@ pub(super) fn blend_options(s: &mut Session, p: &Value) -> Result { }) } +pub(super) fn blend_set_easing(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.blend.setEasing"; + let id = match crate::cmd::object_param(p, "id", C)? { + Some(id) => id, + None => *selected_of(s, is_blend).first().ok_or_else(|| bad(C, "select a blend or give `id`"))?, + }; + let doc = &s.doc()?.doc; + let Some(NodeKind::Blend { children, spec }) = doc.node(id).map(|n| &n.kind) else { return Err(bad(C, "not a blend")) }; + if !doc.is_editable(id) { + return Err(bad(C, "the object is hidden or locked")); + } + let ease = ease_param(p, C, spec.ease)?; + let color_shift = percent_param(p, C, "colorShift", spec.color_shift, -100.0)?; + // Easing moves the steps along a spine, so it needs one with a length to run along. + let easing = ["easingType", "ramp"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null())); + let spined = spec.eases_along_spine() + && live::blend_spine(children, spec).and_then(|(path, _)| Spine::new(&path)).is_some_and(|sp| sp.length().is_finite() && sp.length() > 0.0); + if easing && !spined { + return Err(bad(C, "the blend has no spine")); + } + let changed = ease != spec.ease || color_shift != spec.color_shift; + if changed { + s.edit("Blend Options", |d, _| { + if let Some(NodeKind::Blend { spec, .. }) = d.node_mut(id).map(|n| &mut n.kind) { + spec.ease = ease; + spec.color_shift = color_shift; + } + Ok(()) + })?; + } + Ok(json!({ "id": id.0, "easingType": ease.kind.id(), "ramp": ease.ramp * 100.0, "colorShift": color_shift, "changed": changed })) +} + /// Spacing and orientation as `object.blend.options` takes them; the step count and distance /// not in use show their defaults. fn blend_options_json(spacing: BlendSpacing, orientation: BlendOrientation) -> Value { diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index 5480f7ae58..4cbc5e130f 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -1,6 +1,6 @@ use super::blend::{ blend_create_spine, blend_expand, blend_info, blend_make, blend_options, blend_release, blend_replace_spine, blend_reverse_spine, - blend_reverse_stack, spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment, + blend_reverse_stack, blend_set_easing, spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment, }; use super::common::{has_blend, has_envelope, has_mesh}; use super::envelope::{ @@ -49,6 +49,15 @@ pub fn specs() -> Vec { always, blend_create_spine ), + cmd!( + "object.blend.setEasing", + "Blend Easing", + [], + None, + "{id? (default: the first selected blend), easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100)} set one blend's easing and color shift, as the blend widgets on the canvas do; omitted ones keep their value. easingType and ramp need a blend whose steps follow a spine (a replaced or edited spine, or Align to Path, on a blend not made without a spine); out of range, a hidden or locked blend and a blend with no spine are errors. A value that changes nothing records no undo step → {id, easingType, ramp, colorShift, changed}", + has_doc, + blend_set_easing + ), cmd!( "object.blend.expand", "Expand", diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 5c03317fa9..a562b7e332 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -238,6 +238,7 @@ pub(super) const ALLOWED: &[&str] = &[ "object.blend.replaceSpine", "object.blend.reverseFrontToBack", "object.blend.reverseSpine", + "object.blend.setEasing", "object.blend.spine.addAnchor", "object.blend.spine.convertAnchor", "object.blend.spine.moveAnchor", diff --git a/crates/engine/src/tests/blendopts.rs b/crates/engine/src/tests/blendopts.rs index 4b391c5714..a71e835108 100644 --- a/crates/engine/src/tests/blendopts.rs +++ b/crates/engine/src/tests/blendopts.rs @@ -405,6 +405,137 @@ fn a_document_with_bad_blend_easing_numbers_opens_clamped_while_commands_refuse( } } +/// `object.blend.setEasing` sets one blend's easing and color shift, records no undo step when +/// nothing changes, and refuses bad values, a blend whose steps follow no spine (easing only), a +/// hidden or locked blend and a target that is not a blend, leaving the blend and the history alone. +#[test] +fn set_easing_edits_one_blend_and_refuses_what_it_cannot_set() { + let mut s = session(); + let (a, b) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0)); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3})).unwrap()); + let depth = |s: &Session| s.doc().unwrap().history.undo.len(); + let d0 = depth(&s); + // Align to Page along the straight lines between the keys: the steps follow no spine. + for bad in [json!({"ramp": 30}), json!({"easingType": "easeOut"})] { + let e = s.execute("object.blend.setEasing", &bad).unwrap_err().to_string(); + assert!(e.contains("the blend has no spine"), "{e}"); + } + assert_eq!(depth(&s), d0); + let r = s.execute("object.blend.setEasing", &json!({"colorShift": 25})).unwrap(); + assert_eq!(r, json!({"id": g.0, "easingType": "easeIn", "ramp": 0.0, "colorShift": 25.0, "changed": true})); + assert_eq!((spec(&s, g).1.color_shift, depth(&s)), (25.0, d0 + 1)); + let r = s.execute("object.blend.setEasing", &json!({"colorShift": 25})).unwrap(); + assert_eq!((&r["changed"], depth(&s)), (&json!(false), d0 + 1), "an unchanged value records no undo step"); + // Along the spine each of the four types keeps its kind through a ramp change and its undo. + s.execute("object.blend.options", &json!({"orientation": "path"})).unwrap(); + for kind in ["easeIn", "easeOut", "easeInOut", "easeOutIn"] { + s.execute("object.blend.setEasing", &json!({"easingType": kind, "ramp": 60})).unwrap(); + let r = s.execute("object.blend.setEasing", &json!({"ramp": 20})).unwrap(); + assert_eq!((&r["easingType"], &r["ramp"], &r["colorShift"]), (&json!(kind), &json!(20.0), &json!(25.0))); + s.execute("edit.undo", &json!({})).unwrap(); + let sp = spec(&s, g).1; + assert_eq!((sp.ease.kind.id(), sp.ease.ramp, sp.color_shift), (kind, 0.6, 25.0)); + } + let before = spec(&s, g).1; + let d1 = depth(&s); + for bad in [ + json!({"ramp": 101}), + json!({"ramp": -1}), + json!({"ramp": "x"}), + json!({"colorShift": 1e308}), + json!({"colorShift": -101}), + json!({"easingType": "sideways"}), + json!({"id": "abc", "ramp": 10}), + json!({"id": a.0, "ramp": 10}), + ] { + assert!(s.execute("object.blend.setEasing", &bad).is_err(), "{bad}"); + assert_eq!((spec(&s, g).1, depth(&s)), (before.clone(), d1), "{bad}"); + } + // An explicit id works with nothing selected; nothing selected and no id is an error. + s.execute("select.none", &json!({})).unwrap(); + assert!(s.execute("object.blend.setEasing", &json!({"ramp": 10})).is_err()); + s.execute("object.blend.setEasing", &json!({"id": g.0, "ramp": 10})).unwrap(); + assert_eq!(spec(&s, g).1.ease.ramp, 0.1); + // A hidden or locked blend is refused. + for hide in [true, false] { + s.edit("state", |d, _| { + let n = d.node_mut(g).unwrap(); + (n.visible, n.locked) = (!hide, !hide); + Ok(()) + }) + .unwrap(); + let (before, d2) = (spec(&s, g).1, depth(&s)); + let e = s.execute("object.blend.setEasing", &json!({"id": g.0, "colorShift": 5})).unwrap_err().to_string(); + assert!(e.contains("the object is hidden or locked"), "{e}"); + assert_eq!((spec(&s, g).1, depth(&s)), (before, d2)); + } + // A blend made without a spine takes no easing, Align to Path included; its color shift still + // changes. + let (c, d) = (rect(&mut s, 0.0, 300.0, 40.0), rect(&mut s, 200.0, 300.0, 40.0)); + sel(&mut s, &[c, d]); + let n = id_of(&s.execute("object.blend.make", &json!({"createSpine": "never", "orientation": "path"})).unwrap()); + let d3 = depth(&s); + let e = s.execute("object.blend.setEasing", &json!({"id": n.0, "ramp": 30})).unwrap_err().to_string(); + assert!(e.contains("the blend has no spine"), "{e}"); + assert_eq!(depth(&s), d3); + s.execute("object.blend.setEasing", &json!({"id": n.0, "colorShift": 10})).unwrap(); + assert_eq!(spec(&s, n).1.color_shift, 10.0); +} + +/// Dragging a blend widget with the Selection tool or the Blend tool is one undo step; a click, a +/// drag that ends at its starting value and a drag canceled with Escape record none, and Escape +/// puts the value back. +#[test] +fn dragging_a_blend_widget_is_one_undo_step_and_a_click_or_escape_none() { + let mut s = session(); + let (a, b) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0)); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3, "orientation": "path", "easingType": "easeOut"})).unwrap()); + let v = ViewInfo::default(); + let depth = |s: &Session| s.doc().unwrap().history.undo.len(); + let ptr = |s: &mut Session, kind: PointerKind, x: f64, y: f64| { + s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap(); + }; + s.select_tool("selection", v).unwrap(); + sel(&mut s, &[g]); + let d0 = depth(&s); + // The Easing widget sits 16 px below the spine's halfway point (120, 20). + ptr(&mut s, PointerKind::Down, 120.0, 36.0); + ptr(&mut s, PointerKind::Drag, 135.0, 36.0); + ptr(&mut s, PointerKind::Drag, 150.0, 36.0); + ptr(&mut s, PointerKind::Up, 150.0, 36.0); + let sp = spec(&s, g).1; + assert_eq!((sp.ease.kind.id(), sp.ease.ramp, sp.color_shift, depth(&s)), ("easeOut", 0.3, 0.0, d0 + 1)); + assert_eq!(s.doc().unwrap().selection.objects, vec![g], "the blend stays selected"); + // A click, and a drag back to where it started. + ptr(&mut s, PointerKind::Down, 120.0, 36.0); + ptr(&mut s, PointerKind::Up, 120.0, 36.0); + ptr(&mut s, PointerKind::Down, 120.0, 36.0); + ptr(&mut s, PointerKind::Drag, 160.0, 36.0); + ptr(&mut s, PointerKind::Drag, 120.0, 36.0); + ptr(&mut s, PointerKind::Up, 120.0, 36.0); + assert_eq!((spec(&s, g).1.ease.ramp, depth(&s)), (0.3, d0 + 1)); + // Escape during a drag puts the value back. + ptr(&mut s, PointerKind::Down, 120.0, 36.0); + ptr(&mut s, PointerKind::Drag, 170.0, 36.0); + assert_eq!(spec(&s, g).1.ease.ramp, 0.8, "the drag previews"); + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + ptr(&mut s, PointerKind::Up, 170.0, 36.0); + assert_eq!((spec(&s, g).1.ease.ramp, depth(&s)), (0.3, d0 + 1)); + // The Blend tool drags the Appearance widget, 16 px above: the color shift alone. + s.select_tool("blend", v).unwrap(); + sel(&mut s, &[g]); + ptr(&mut s, PointerKind::Down, 120.0, 4.0); + ptr(&mut s, PointerKind::Drag, 100.0, 4.0); + ptr(&mut s, PointerKind::Up, 100.0, 4.0); + let sp = spec(&s, g).1; + assert_eq!((sp.ease.kind.id(), sp.ease.ramp, sp.color_shift, depth(&s)), ("easeOut", 0.3, -20.0, d0 + 2)); + assert_eq!(s.doc().unwrap().selection.objects, vec![g]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, g).1.color_shift, 0.0); +} + #[test] fn native_blend_easing_values_clamp_and_non_finite_become_default() { for (bad, ramp, shift) in [ diff --git a/crates/tools/src/blendwidget.rs b/crates/tools/src/blendwidget.rs new file mode 100644 index 0000000000..634e2639a1 --- /dev/null +++ b/crates/tools/src/blendwidget.rs @@ -0,0 +1,536 @@ +//! Blend widgets: two handles beside the spine of one selected blend, at the point halfway along +//! the spine, one 16 screen pixels above it and one 16 pixels below it. Where other artwork crowds +//! that spot (the boxes of three or more other visible objects hold it), a widget moves further +//! out in 16-pixel steps, up to 144 pixels, to the first spot that is not crowded, and the line +//! the canvas draws from the halfway point to the widget grows with it. The upper one sets the +//! blend's color shift (`colorShift`, -100 to 100) and the lower one its easing strength (`ramp`, +//! 0 to 100). The lower one shows only when the steps follow a spine (a replaced or edited spine, +//! or Align to Path), where easing moves them; a blend made without a spine shows neither. A drag along the spine's direction at the press +//! changes the value by one per screen pixel, starting from the value at the press, and the arrow +//! keys during the drag change it by one (Shift: by ten); Escape or a switch to another tool +//! cancels the drag and puts the value back. Each drag previews +//! `object.blend.setEasing` and is one undo step, or none when the value ends where it started; a +//! click changes nothing. The Selection and Blend tools hit the widgets and the canvas draws them +//! from the same geometry. + +use serde_json::json; +use vectorcraft_doc::live::{Spine, blend_spine}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection}; +use vectorcraft_geom::{Point, Rect, Vec2}; + +use crate::{Action, Overlay, PointerEvent, ToolContext}; + +/// How far (screen px) a widget's center sits above or below the spine's halfway point. +pub const OFFSET_PX: f64 = 16.0; +/// The widget's radius (screen px), as the canvas draws it. +pub const RADIUS_PX: f32 = 4.0; +/// How near (screen px) the pointer must be to hit a widget. +const HIT_PX: f64 = 5.0; +/// Points of value per screen pixel of drag along the spine. +const PER_PX: f64 = 1.0; +/// The undo step a drag records, as the Blend panel's changes do. +const LABEL: &str = "Blend Options"; +/// How many [`OFFSET_PX`] steps further out a widget may move from a crowded spot. +const CLEAR_STEPS: usize = 8; +/// A spot is crowded when the boxes of this many other visible objects hold it. +const CROWDED: usize = 3; +/// The most nodes looked at to find what crowds the widgets, topmost first. +const LOOK: usize = 1024; + +/// What a blend widget sets. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BlendControl { + /// The upper widget: the color shift. + Appearance, + /// The lower widget: the easing strength. + Easing, +} + +impl BlendControl { + /// The `object.blend.setEasing` param this widget sets. + fn key(self) -> &'static str { + match self { + Self::Appearance => "colorShift", + Self::Easing => "ramp", + } + } + + /// The values the param takes. + fn range(self) -> (f64, f64) { + match self { + Self::Appearance => (-100.0, 100.0), + Self::Easing => (0.0, 100.0), + } + } +} + +/// One widget of the selected blend. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BlendWidget { + pub id: NodeId, + pub control: BlendControl, + /// The spine's halfway point, in the document. + pub on: Point, + /// The widget's center in the document: [`OFFSET_PX`] from `on`, or further out where other + /// artwork crowds that spot. + pub at: Point, + /// The spine's direction at `on` (unit length, in the document). + pub along: Vec2, + /// The value the widget sets, as it is now. + pub value: f64, +} + +impl BlendWidget { + /// The widgets of the selection at `zoom` (screen px per document point), with `up` the + /// document direction of the screen's up: the Appearance widget, then the Easing widget. Empty + /// unless the selection is one whole blend, visible and unlocked, with two or more keys and a + /// spine of some length, and no anchors are selected. Each widget sits at the first spot out + /// from the spine, in [`OFFSET_PX`] steps, that the boxes of fewer than three other visible + /// objects hold, at most nine steps out; where every spot is crowded it stays at the first. + pub fn of(doc: &Document, selection: &Selection, zoom: f64, up: Vec2) -> Vec { + let up_len = up.hypot(); + let usable = |v: f64| v.is_finite() && v > 0.0; + if !selection.anchors.is_empty() || selection.symbol_part.is_some() || !usable(zoom) || !usable(up_len) { + return vec![]; + } + let [id] = selection.objects.as_slice() else { return vec![] }; + let Some(NodeKind::Blend { children, spec }) = doc.node(*id).map(|n| &n.kind) else { return vec![] }; + if children.len() < 2 || !doc.is_editable(*id) { + return vec![]; + } + let Some(spine) = blend_spine(children, spec).and_then(|(path, _)| Spine::new(&path)) else { return vec![] }; + if !usable(spine.length()) { + return vec![]; + } + let (on, angle) = spine.at(0.5); + let along = Vec2::new(angle.cos(), angle.sin()); + if !(on.x.is_finite() && on.y.is_finite() && along.x.is_finite() && along.y.is_finite()) { + return vec![]; + } + let lift = up * (OFFSET_PX / zoom / up_len); + let reach = lift.hypot() * (CLEAR_STEPS + 1) as f64 + f64::from(RADIUS_PX) / zoom; + let boxes = boxes_near(doc, *id, Rect::new(on.x - reach, on.y - reach, on.x + reach, on.y + reach)); + let clear = |side: Vec2| { + let crowded = |p: Point| boxes.iter().filter(|b| b.x0 <= p.x && p.x <= b.x1 && b.y0 <= p.y && p.y <= b.y1).count() >= CROWDED; + (1..=CLEAR_STEPS + 1).map(|k| on + side * k as f64).find(|p| !crowded(*p)).unwrap_or(on + side) + }; + let value = |v: f64, control: BlendControl| { + let (lo, hi) = control.range(); + if v.is_finite() { v.clamp(lo, hi) } else { 0.0 } + }; + let shift = value(spec.color_shift, BlendControl::Appearance); + let mut out = vec![Self { id: *id, control: BlendControl::Appearance, on, at: clear(lift), along, value: shift }]; + if spec.eases_along_spine() { + let ramp = value(spec.ease.ramp * 100.0, BlendControl::Easing); + out.push(Self { id: *id, control: BlendControl::Easing, on, at: clear(-lift), along, value: ramp }); + } + out + } + + /// The widgets the tool in `cx` can drag. + pub fn for_tool(cx: &ToolContext) -> Vec { + Self::of(cx.doc, cx.selection, cx.zoom, screen_up(cx)) + } + + /// The widget of `cx` nearest `p` within reach. + fn at_point(cx: &ToolContext, p: Point) -> Option { + let d = |w: &Self| w.at.distance(p); + Self::for_tool(cx).into_iter().filter(|w| d(w) <= cx.tol(HIT_PX)).min_by(|a, b| d(a).total_cmp(&d(b))) + } +} + +/// The document direction of the screen's up (the page's up without a window). +fn screen_up(cx: &ToolContext) -> Vec2 { + cx.screen.map(|s| -s.down).filter(|v| v.x.is_finite() && v.y.is_finite() && v.hypot() > 0.0).unwrap_or(Vec2::new(0.0, -1.0)) +} + +/// The geometric boxes of the visible objects other than blend `skip` that reach into `region`. +/// The walk goes into visible layers and groups without a clipping path, topmost first, and looks +/// at no more than [`LOOK`] nodes; any other object counts by its own box. +fn boxes_near(doc: &Document, skip: NodeId, region: Rect) -> Vec { + let reaches = |b: &Rect| b.x0 <= region.x1 && region.x0 <= b.x1 && b.y0 <= region.y1 && region.y0 <= b.y1; + // Each node is queued at most once, so `room` bounds both the queue and the walk; a container + // with more members than there is room for queues its topmost ones. + fn queue<'a>(nodes: &'a [std::sync::Arc], skip: NodeId, room: &mut usize) -> impl Iterator { + let take = nodes.len().min(*room); + *room -= take; + nodes.iter().skip(nodes.len() - take).filter(move |c| c.visible && c.id != skip).map(|c| &**c) + } + let mut room = LOOK; + let mut stack: Vec<&Node> = queue(&doc.layers, skip, &mut room).collect(); + let mut out = vec![]; + while let Some(n) = stack.pop() { + match &n.kind { + NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { + stack.extend(queue(children, skip, &mut room)); + } + _ => out.extend(n.geometric_bounds().filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && reaches(b))), + } + } + out +} + +/// Is `p` over a blend widget? +pub fn over_widget(cx: &ToolContext, p: Point) -> bool { + BlendWidget::at_point(cx, p).is_some() +} + +/// Dragging a blend widget: its value follows the pointer along the spine's direction at the +/// press. +#[derive(Clone, Debug)] +pub struct BlendWidgetDrag { + widget: BlendWidget, + start: Point, + /// The pointer's last position. + at: Point, + /// What the arrow keys added. + keys: f64, + began: bool, + value: f64, +} + +impl BlendWidgetDrag { + /// The drag has left the click: the value is changing. + pub fn adjusting(&self) -> bool { + self.began + } + + /// Start a drag when the press `ev` is on a blend widget of the selection. + pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option { + let widget = BlendWidget::at_point(cx, ev.pos)?; + Some(Self { widget, start: ev.pos, at: ev.pos, keys: 0.0, began: false, value: widget.value }) + } + + /// The value at the press plus the pointer's move along the spine, before the keys' steps + /// and the range. + fn dragged(&self, cx: &ToolContext) -> f64 { + let moved = (self.at - self.start).dot(self.widget.along) * cx.zoom; + self.widget.value + if moved.is_finite() { (moved * PER_PX).round() } else { 0.0 } + } + + /// The value for the pointer at `self.at` and the keys pressed, within the range. + fn value_now(&self, cx: &ToolContext) -> f64 { + let (lo, hi) = self.widget.control.range(); + (self.dragged(cx) + self.keys).clamp(lo, hi) + } + + /// The actions that show the value now: the drag's start when it begins, then a preview. + fn preview(&mut self, cx: &ToolContext) -> Vec { + let mut out = vec![]; + if !self.began { + self.began = true; + out.push(Action::Begin(LABEL.into())); + } + self.value = self.value_now(cx); + let mut params = json!({ "id": self.widget.id.0 }); + params[self.widget.control.key()] = json!(self.value); + out.push(Action::Preview("object.blend.setEasing".into(), params)); + out + } + + pub fn drag(&mut self, cx: &ToolContext, p: Point) -> Vec { + self.at = p; + if !self.began && p.distance(self.start) < cx.tol(3.0) { + return vec![]; + } + self.preview(cx) + } + + /// An arrow key during the drag: one point more (`more`) or less, ten with Shift. + pub fn step(&mut self, cx: &ToolContext, more: bool, big: bool) -> Vec { + let by = if big { 10.0 } else { 1.0 }; + self.keys += if more { by } else { -by }; + // Past either end, the keys stop adding: the way back starts at once. + let (lo, hi) = self.widget.control.range(); + let free = self.dragged(cx); + self.keys = self.keys.clamp(lo - free, hi - free); + self.preview(cx) + } + + pub fn finish(self) -> Vec { + if self.began { vec![Action::Commit] } else { vec![] } + } + + /// The value next to the pointer. + pub fn overlays(&self, cx: &ToolContext) -> Vec { + if !self.began || !cx.measurement_labels { + return vec![]; + } + vec![Overlay::Measure { p: self.at, text: format!("{:.0}%", self.value) }] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::select::SelectionTool; + use crate::testutil::*; + use crate::{Mods, PointerKind, Tool, ToolKey}; + use std::sync::Arc; + use vectorcraft_doc::live::{BlendEase, BlendEaseType, BlendOrientation, BlendSpec}; + use vectorcraft_doc::{Appearance, Node}; + use vectorcraft_geom::{PathData, Rect, SubPath, shapes}; + + const UP: Vec2 = Vec2::new(0.0, -1.0); + + fn square(d: &mut Document, x: f64, y: f64) -> Arc { + let id = d.alloc_id(); + Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), Appearance::default_art())) + } + + /// A blend of squares centered at `centers`, with `spec`, selected. + fn blend_doc(centers: &[(f64, f64)], spec: BlendSpec) -> (Document, NodeId, Selection) { + let mut d = Document::new(800.0, 800.0); + let l = d.layers[0].id; + let children = centers.iter().map(|(x, y)| square(&mut d, x - 10.0, y - 10.0)).collect(); + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::new(id, NodeKind::Blend { children, spec: Box::new(spec) })).unwrap(); + let mut s = Selection::default(); + s.add(id); + (d, id, s) + } + + /// Steps along a spine (Align to Path), eased In Out at 40%, color shift -20. + fn eased() -> BlendSpec { + BlendSpec { + orientation: BlendOrientation::AlignToPath, + ease: BlendEase { kind: BlendEaseType::InOut, ramp: 0.4 }, + color_shift: -20.0, + ..Default::default() + } + } + + fn ev(kind: PointerKind, p: Point) -> PointerEvent { + PointerEvent::new(kind, p.x, p.y) + } + + fn near(a: Point, b: Point) -> bool { + a.distance(b) < 1e-9 + } + + #[test] + fn the_widgets_sit_above_and_below_the_spines_halfway_point() { + let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased()); + let w = BlendWidget::of(&d, &s, 1.0, UP); + assert_eq!(w.iter().map(|w| w.control).collect::>(), [BlendControl::Appearance, BlendControl::Easing]); + assert!(w.iter().all(|w| w.id == id && near(w.on, Point::new(200.0, 200.0))), "{w:?}"); + assert!(near(w[0].at, Point::new(200.0, 184.0)) && near(w[1].at, Point::new(200.0, 216.0)), "{w:?}"); + assert_eq!((w[0].value, w[1].value), (-20.0, 40.0)); + // 16 screen pixels at any zoom, and above on the screen in a turned view. + assert!(near(BlendWidget::of(&d, &s, 4.0, UP)[0].at, Point::new(200.0, 196.0))); + let turned = BlendWidget::of(&d, &s, 1.0, Vec2::new(1.0, 0.0)); + assert!(near(turned[0].at, Point::new(216.0, 200.0)) && near(turned[1].at, Point::new(184.0, 200.0)), "{turned:?}"); + // Halfway along by length: three keys spaced unevenly. + let (d, _, s) = blend_doc(&[(0.0, 0.0), (100.0, 0.0), (100.0, 300.0)], eased()); + assert!(near(BlendWidget::of(&d, &s, 1.0, UP)[0].on, Point::new(100.0, 100.0))); + } + + /// Adds a filled rectangle from `(x0, y0)` to `(x1, y1)` on top of the first layer. + fn cover(d: &mut Document, x0: f64, y0: f64, x1: f64, y1: f64) -> NodeId { + let l = d.layers[0].id; + let id = d.alloc_id(); + let at = d.layers[0].children().map_or(0, |c| c.len()); + d.insert(Some(l), at, Node::path(id, shapes::rectangle(Rect::new(x0, y0, x1, y1)), Appearance::default_art())).unwrap(); + id + } + + #[test] + fn a_widget_moves_out_from_crowded_artwork_and_its_line_follows() { + let (mut d, _, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased()); + // Two objects under the Appearance widget's spot do not crowd it. + for _ in 0..2 { + cover(&mut d, 190.0, 175.0, 210.0, 195.0); + } + assert!(near(BlendWidget::of(&d, &s, 1.0, UP)[0].at, Point::new(200.0, 184.0))); + // A third does: the widget moves 16 px further out, past them, and the Easing widget below + // stays where it is. The halfway point it joins stays the same. + let third = cover(&mut d, 190.0, 175.0, 210.0, 195.0); + let w = BlendWidget::of(&d, &s, 1.0, UP); + assert!(near(w[0].at, Point::new(200.0, 168.0)) && near(w[0].on, Point::new(200.0, 200.0)), "{w:?}"); + assert!(near(w[1].at, Point::new(200.0, 216.0)), "{w:?}"); + // A hidden object does not count. + d.node_mut(third).unwrap().visible = false; + assert!(near(BlendWidget::of(&d, &s, 1.0, UP)[0].at, Point::new(200.0, 184.0))); + // The walk looks at the topmost 1024 nodes only: with more objects above them, the covers + // below are not looked at. + d.node_mut(third).unwrap().visible = true; + let mut many = d.clone(); + for i in 0..LOOK { + let x = (i % 30) as f64 * 25.0; + cover(&mut many, x, 500.0, x + 10.0, 510.0); + } + assert!(near(BlendWidget::of(&many, &s, 1.0, UP)[0].at, Point::new(200.0, 184.0))); + assert!(near(BlendWidget::of(&d, &s, 1.0, UP)[0].at, Point::new(200.0, 168.0))); + // Crowded all the way out (past 144 px): the widget stays at the first spot. + let (mut d, _, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased()); + for _ in 0..3 { + cover(&mut d, 150.0, 0.0, 250.0, 199.0); + } + assert!(near(BlendWidget::of(&d, &s, 1.0, UP)[0].at, Point::new(200.0, 184.0))); + // The blend's own keys never crowd it: three 100 pt keys that all hold both spots. + let mut d = Document::new(800.0, 800.0); + let l = d.layers[0].id; + let big = |d: &mut Document, x: f64| { + let id = d.alloc_id(); + Arc::new(Node::path(id, shapes::rectangle(Rect::new(x - 50.0, 150.0, x + 50.0, 250.0)), Appearance::default_art())) + }; + let children = vec![big(&mut d, 150.0), big(&mut d, 200.0), big(&mut d, 250.0)]; + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::new(id, NodeKind::Blend { children, spec: Box::new(eased()) })).unwrap(); + let mut s = Selection::default(); + s.add(id); + let w = BlendWidget::of(&d, &s, 1.0, UP); + assert!(near(w[0].at, Point::new(200.0, 184.0)) && near(w[1].at, Point::new(200.0, 216.0)), "{w:?}"); + } + + #[test] + fn the_easing_widget_needs_steps_that_follow_a_spine() { + // The straight lines between the keys with Align to Page: the steps do not follow a spine. + let (d, _, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], BlendSpec::default()); + assert_eq!(BlendWidget::of(&d, &s, 1.0, UP).iter().map(|w| w.control).collect::>(), [BlendControl::Appearance]); + // A replaced spine, closed, with the keys spread along it. + let spine = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(200.0, 0.0), Point::new(200.0, 200.0)], true)); + let (d, _, s) = blend_doc(&[(0.0, 0.0), (200.0, 0.0)], BlendSpec { spine: Some(spine), ..Default::default() }); + let w = BlendWidget::of(&d, &s, 1.0, UP); + assert_eq!(w.len(), 2, "{w:?}"); + assert!(w[0].on.x.is_finite() && w[0].on.y.is_finite()); + } + + #[test] + fn no_widgets_for_a_selection_they_cannot_edit() { + let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased()); + assert!(BlendWidget::of(&d, &Selection::default(), 1.0, UP).is_empty(), "nothing selected"); + assert!(BlendWidget::of(&d, &s, 0.0, UP).is_empty() && BlendWidget::of(&d, &s, f64::NAN, UP).is_empty(), "no zoom"); + assert!(BlendWidget::of(&d, &s, 1.0, Vec2::ZERO).is_empty(), "no up"); + // Two blends, or a blend and another object. + let (mut two, a, _) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased()); + let l = two.layers[0].id; + let keys = vec![square(&mut two, 0.0, 400.0), square(&mut two, 200.0, 400.0)]; + let b = two.alloc_id(); + two.insert(Some(l), 0, Node::new(b, NodeKind::Blend { children: keys, spec: Box::new(eased()) })).unwrap(); + let mut both = Selection::default(); + both.add(a); + both.add(b); + assert!(BlendWidget::of(&two, &both, 1.0, UP).is_empty(), "two blends"); + // Locked or hidden. + for hide in [false, true] { + let mut d = d.clone(); + let n = d.node_mut(id).unwrap(); + (n.visible, n.locked) = (!hide, !hide); + assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "hidden: {hide}"); + } + // A key object selected on its own is not the whole blend. + let NodeKind::Blend { children, .. } = &d.node(id).unwrap().kind else { panic!("not a blend") }; + let mut key = Selection::default(); + key.add(children[0].id); + assert!(BlendWidget::of(&d, &key, 1.0, UP).is_empty(), "a key"); + // Keys on one spot: the spine has no length. + let (d, _, s) = blend_doc(&[(100.0, 200.0), (100.0, 200.0)], eased()); + assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "no length"); + // One key. + let (d, _, s) = blend_doc(&[(100.0, 200.0)], eased()); + assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "one key"); + // A blend made without a spine (Create Spine › Never), Align to Path included. + let (d, _, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], BlendSpec { no_spine: true, ..eased() }); + assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "no spine"); + } + + /// A drag of the Easing widget along the spine changes the ramp by one per pixel from its + /// value at the press, in one undo step, without changing the type or the color shift. + #[test] + fn dragging_the_easing_widget_sets_the_ramp_from_its_value() { + let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased()); + let p = paint(); + let c = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + let at = Point::new(200.0, 216.0); + assert!(t.pointer(&c, &ev(PointerKind::Down, at)).is_empty()); + // A small move first: no jump, the value stays at the press's 40. + assert!(t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(1.0, 1.0))).is_empty()); + let a = t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(3.0, 30.0))); + assert_eq!(a[0], Action::Begin(LABEL.into())); + assert_eq!(a[1], Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 43.0})), "across the spine counts for nothing"); + assert!(t.transforming()); + assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == "43%"))); + let a = t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(-70.0, 0.0))); + assert_eq!(a, vec![Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 0.0}))], "clamped at 0"); + let a = t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(500.0, 0.0))); + assert_eq!(a, vec![Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 100.0}))], "clamped at 100"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, at)), vec![Action::Commit]); + // A click that doesn't move sends nothing. + assert!(t.pointer(&c, &ev(PointerKind::Down, at)).is_empty()); + assert!(t.pointer(&c, &ev(PointerKind::Up, at)).is_empty()); + } + + /// The Appearance widget sets the color shift alone, and at 4× zoom a pixel is a quarter point. + #[test] + fn dragging_the_appearance_widget_sets_the_color_shift() { + let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased()); + let p = paint(); + let c = ToolContext { zoom: 4.0, ..cx(&d, &s, &p) }; + let mut t = SelectionTool::default(); + let at = Point::new(200.0, 196.0); + t.pointer(&c, &ev(PointerKind::Down, at)); + let a = t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(-5.0, 0.0))); + assert_eq!(a[1], Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "colorShift": -40.0}))); + let a = t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(-50.0, 0.0))); + assert_eq!(a, vec![Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "colorShift": -100.0}))]); + } + + /// The drag follows the spine's direction at the press: along a vertical spine, down adds. + #[test] + fn the_drag_follows_the_spines_direction() { + let (d, id, s) = blend_doc(&[(200.0, 100.0), (200.0, 300.0)], eased()); + let p = paint(); + let c = cx(&d, &s, &p); + let w = BlendWidget::of(&d, &s, 1.0, UP); + let mut t = SelectionTool::default(); + t.pointer(&c, &ev(PointerKind::Down, w[1].at)); + let a = t.pointer(&c, &ev(PointerKind::Drag, w[1].at + Vec2::new(40.0, 10.0))); + assert_eq!(a[1], Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 50.0}))); + } + + /// Switching away from the Selection or Blend tool during a drag cancels it, and after a press + /// that has not moved there is nothing to put back. + #[test] + fn a_tool_switch_cancels_the_drag() { + let (d, _, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased()); + let p = paint(); + let c = cx(&d, &s, &p); + let at = Point::new(200.0, 216.0); + let tools: [Box; 2] = [Box::new(SelectionTool::default()), Box::new(crate::meshblend::BlendTool::default())]; + for mut t in tools { + t.pointer(&c, &ev(PointerKind::Down, at)); + assert_eq!(t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(20.0, 0.0))).len(), 2); + assert_eq!(t.deactivate(&c), vec![Action::Cancel]); + assert!(!t.busy() && !t.transforming()); + t.pointer(&c, &ev(PointerKind::Down, at)); + assert!(t.busy()); + assert!(t.deactivate(&c).is_empty()); + assert!(!t.busy()); + } + } + + /// The arrow keys during a drag change the value by one, ten with Shift, and Escape cancels. + #[test] + fn arrow_keys_step_and_escape_cancels() { + let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased()); + let p = paint(); + let c = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + let at = Point::new(200.0, 216.0); + t.pointer(&c, &ev(PointerKind::Down, at)); + let a = t.key(&c, ToolKey::Up, Mods::default()); + assert_eq!(a, vec![Action::Begin(LABEL.into()), Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 41.0}))]); + let a = t.key(&c, ToolKey::Left, Mods { shift: true, ..Default::default() }); + assert_eq!(a, vec![Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 31.0}))]); + for _ in 0..10 { + t.key(&c, ToolKey::Down, Mods { shift: true, ..Default::default() }); + } + let a = t.key(&c, ToolKey::Right, Mods::default()); + assert_eq!(a, vec![Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 1.0}))], "steps past 0 are not kept"); + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); + assert!(!t.busy() && !t.transforming()); + assert!(t.pointer(&c, &ev(PointerKind::Up, at)).is_empty(), "the release commits nothing"); + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index c397231495..b8069e9271 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -9,6 +9,7 @@ pub mod addons; pub mod bbox; +pub mod blendwidget; pub mod builder; pub mod catalog; pub mod corners; diff --git a/crates/tools/src/meshblend.rs b/crates/tools/src/meshblend.rs index d64b4b162b..d61eb95f5f 100644 --- a/crates/tools/src/meshblend.rs +++ b/crates/tools/src/meshblend.rs @@ -4,6 +4,7 @@ //! point of a path blends from that point (`starts`). Each further click adds the object as one //! more key of the new blend (as does clicking a blend first). Double-click or Alt-click opens //! Blend Options (dialog `blendOptions`). Clicking empty canvas or pressing Escape starts over. +//! A press on a widget of the selected blend drags it ([`crate::blendwidget`]). //! - Mesh: click inside a filled path → `object.mesh.create {ids, at}` (1×1 mesh plus lines through //! the click); click inside a mesh or a mesh envelope → `object.mesh.addLine` (a gradient mesh's //! new point takes the current fill color in its own model, unless Shift); drag a mesh point, @@ -19,6 +20,7 @@ use vectorcraft_color::Paint; use vectorcraft_doc::{NodeId, NodeKind}; use vectorcraft_geom::Point; +use crate::blendwidget::{self, BlendWidgetDrag}; use crate::meshedit::{MeshEdit, grid_of}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; @@ -51,6 +53,8 @@ pub struct BlendTool { /// `first` and `chained` when the button went down: a dropped press puts them back, unless /// the press picked an object or made a blend. before_press: Option<(Option, bool)>, + /// A press on a widget of the selected blend, and its drag. + widget: Option, } /// The anchor of the first subpath of path or compound path `id` (its first member's) nearest @@ -98,6 +102,19 @@ impl Tool for BlendTool { if self.first.is_some_and(|(a, _)| cx.doc.node(a).is_none()) { (self.first, self.chained) = (None, false); } + // The selected blend's widgets take a press before the picks, and its drag. + match (ev.kind, &mut self.widget) { + (PointerKind::Drag, Some(d)) => return d.drag(cx, ev.pos), + (PointerKind::Up, Some(_)) => return self.widget.take().map(BlendWidgetDrag::finish).unwrap_or_default(), + (PointerKind::DoubleClick, _) if blendwidget::over_widget(cx, ev.pos) => return vec![], + (PointerKind::Down, _) if !ev.mods.alt => { + if let Some(d) = BlendWidgetDrag::hit(cx, ev) { + self.widget = Some(d); + return vec![]; + } + } + _ => {} + } match ev.kind { PointerKind::DoubleClick => options(self), PointerKind::Down if ev.mods.alt => options(self), @@ -131,6 +148,9 @@ impl Tool for BlendTool { } } fn overlays(&self, cx: &ToolContext) -> Vec { + if let Some(d) = &self.widget { + return d.overlays(cx); + } let Some((id, start)) = self.first(cx) else { return vec![] }; let Some(n) = cx.doc.node(id) else { return vec![] }; let at = match (start, &n.kind) { @@ -144,6 +164,9 @@ impl Tool for BlendTool { vec![Overlay::Anchor { p, color: FEEDBACK, filled: true, size: 6.0 }] } fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor { + if self.widget.is_some() || blendwidget::over_widget(cx, p) { + return Cursor::ArrowHollow; + } match pick(cx, p) { Some((_, Some(_))) => Cursor::BlendAnchor, Some(_) => Cursor::BlendObject, @@ -151,15 +174,34 @@ impl Tool for BlendTool { } } fn busy(&self) -> bool { - self.first.is_some() + self.first.is_some() || self.widget.is_some() + } + fn transforming(&self) -> bool { + self.widget.as_ref().is_some_and(BlendWidgetDrag::adjusting) + } + /// The arrows while a blend widget is pressed: its value. + fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { + self.widget.is_some() && matches!(key, ToolKey::Up | ToolKey::Down | ToolKey::Left | ToolKey::Right) } /// A first pick, or adding to the blend just made: Escape ends either. fn ends_on_escape(&self, cx: &ToolContext) -> bool { self.first(cx).is_some() } /// Escape drops the object picked first and stops adding to the blend just made, so the next - /// click picks a new first object. The document and the selection stay as they are. - fn key(&mut self, _cx: &ToolContext, key: crate::ToolKey, _mods: Mods) -> Vec { + /// click picks a new first object. The document and the selection stay as they are. While a + /// blend widget is pressed, the arrows change its value and Escape cancels its drag instead. + fn key(&mut self, cx: &ToolContext, key: crate::ToolKey, mods: Mods) -> Vec { + if let Some(d) = &mut self.widget { + return match key { + ToolKey::Up | ToolKey::Down | ToolKey::Left | ToolKey::Right => d.step(cx, matches!(key, ToolKey::Up | ToolKey::Right), mods.shift), + ToolKey::Escape => { + let began = d.adjusting(); + self.widget = None; + if began { vec![Action::Cancel] } else { vec![] } + } + _ => vec![], + }; + } if key == crate::ToolKey::Escape { (self.first, self.chained, self.before_press) = (None, false, None); } @@ -172,11 +214,18 @@ impl Tool for BlendTool { if let Some((first, chained)) = self.before_press.take() { (self.first, self.chained) = (first, chained); } - vec![] + // A dropped widget drag puts its value back. + match self.widget.take() { + Some(d) if d.adjusting() => vec![Action::Cancel], + _ => vec![], + } } fn deactivate(&mut self, _cx: &ToolContext) -> Vec { (self.first, self.chained, self.before_press) = (None, false, None); - vec![] + match self.widget.take() { + Some(d) if d.adjusting() => vec![Action::Cancel], + _ => vec![], + } } /// Choosing the Blend tool again finishes the blend: it drops the object picked first and /// stops adding to the blend just made, so the next click picks a new first object. The diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 1658346127..84a10d350f 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -45,6 +45,7 @@ use vectorcraft_doc::{NodeId, OrientedBox}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag}; +use crate::blendwidget::{self, BlendWidgetDrag}; use crate::corners::{self, CornerDrag, over_widget}; use crate::guides::{PointSnap, Targets, distance_guides, hold_on_path}; use crate::objects_on_path::{MoveAllDrag, RotateAllDrag, SpaceDrag, over_move_all, over_rotate_all, over_space}; @@ -129,6 +130,8 @@ enum State { Corner(CornerDrag), /// Dragging a live ellipse's pie widget. Pie(PieDrag), + /// Dragging a selected blend's Appearance or Easing widget. + BlendWidget(BlendWidgetDrag), /// Dragging an Objects on Path group's Rotate All widget. RotateAll(RotateAllDrag), /// Dragging an Objects on Path group's Space widget. @@ -753,15 +756,19 @@ impl Tool for SelectionTool { /// E during a marquee drag switches the marquee between the objects it touches and only the /// objects inside it. Escape during a drag (a move or an Alt-drag copy, a bounding-box scale or - /// rotation, a marquee, a corner widget, a pie widget, the Rotate All, Space or Move All widget, the brackets of type on a path, a mirror + /// rotation, a marquee, a corner widget, a pie widget, a blend widget, the Rotate All, Space or Move All widget, the brackets of type on a path, a mirror /// axis, a grid repeat's handles, a ruler guide) cancels it: what it changed goes back, no copy /// is left, and the tool waits for the next press. With the pointer loaded with text, Escape /// drops the text. - fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { // Up and Down while a corner widget is pressed: the corners' kind (#2898). if let (ToolKey::Up | ToolKey::Down, State::Corner(c)) = (key, &mut self.state) { return c.step_kind(key == ToolKey::Up); } + // The arrows while a blend widget is pressed: its value, by one (Shift: ten). + if let (ToolKey::Up | ToolKey::Down | ToolKey::Left | ToolKey::Right, State::BlendWidget(d)) = (key, &mut self.state) { + return d.step(cx, matches!(key, ToolKey::Up | ToolKey::Right), mods.shift); + } if key == ToolKey::Letter('e') && let State::Marquee { enclosed, .. } = &mut self.state { @@ -812,10 +819,23 @@ impl Tool for SelectionTool { out } + /// Switching tools during a blend widget's drag cancels it, as Escape does: its value goes back. + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + match std::mem::take(&mut self.state) { + State::BlendWidget(d) if d.adjusting() => vec![Action::Cancel], + State::BlendWidget(_) => vec![], + other => { + self.state = other; + vec![] + } + } + } + fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { match self.state { State::Marquee { .. } => key == ToolKey::Letter('e'), State::Corner(_) => matches!(key, ToolKey::Up | ToolKey::Down), + State::BlendWidget(_) => matches!(key, ToolKey::Up | ToolKey::Down | ToolKey::Left | ToolKey::Right), State::Loaded { .. } | State::Mirror(_) | State::GridRepeat(_) | State::NineSlice(_) => key == ToolKey::Escape, _ => false, } @@ -842,6 +862,7 @@ impl Tool for SelectionTool { State::Moving { began: true, .. } | State::Scaling { .. } | State::Rotating { .. } => true, State::Corner(c) => c.adjusting(), State::Pie(d) => d.adjusting(), + State::BlendWidget(d) => d.adjusting(), State::RotateAll(d) => d.adjusting(), State::Space(d) => d.adjusting(), State::MoveAll(d) => d.adjusting(), @@ -1015,6 +1036,15 @@ impl Tool for SelectionTool { self.state = State::Idle; d.finish() } + (PointerKind::Drag, State::BlendWidget(mut d)) => { + let out = d.drag(cx, p); + self.state = State::BlendWidget(d); + out + } + (PointerKind::Up, State::BlendWidget(d)) => { + self.state = State::Idle; + d.finish() + } (PointerKind::Drag, State::RotateAll(mut d)) => { let out = d.drag(cx, p, m.shift); self.state = State::RotateAll(d); @@ -1104,6 +1134,7 @@ impl Tool for SelectionTool { }), State::Corner(c) => o.extend(c.overlays(cx)), State::Pie(d) => o.extend(d.overlays(cx)), + State::BlendWidget(d) => o.extend(d.overlays(cx)), State::RotateAll(d) => o.extend(d.overlays(cx)), State::Space(d) => o.extend(d.overlays(cx)), State::MoveAll(d) => o.extend(d.overlays(cx)), @@ -1155,7 +1186,7 @@ impl Tool for SelectionTool { State::Moving { began: true, .. } if m.alt => return Cursor::SelectionCopy, State::Moving { began: true, .. } => return if self.snapped { Cursor::ArrowHollow } else { Cursor::Arrow }, State::Corner(_) => return Cursor::CornerRadius, - State::Pie(_) => return Cursor::ArrowHollow, + State::Pie(_) | State::BlendWidget(_) => return Cursor::ArrowHollow, State::RotateAll(_) => return Cursor::Rotate, State::Space(_) => return Cursor::ArrowHollow, State::MoveAll(_) => return Cursor::Move, @@ -1178,6 +1209,9 @@ impl Tool for SelectionTool { if near_selected && pie::over_widget(cx, p) { return Cursor::ArrowHollow; } + if blendwidget::over_widget(cx, p) { + return Cursor::ArrowHollow; + } if over_rotate_all(cx, p) { return Cursor::Rotate; } @@ -1536,7 +1570,7 @@ impl SelectionTool { self.state = State::Loaded { from, before: port == crate::text::Port::In, press: None }; return Some(vec![]); } - // 1. Live Corners widgets, pie widgets, the Rotate All, Space and Move All widgets and type on a path's brackets, then the + // 1. Live Corners widgets, pie widgets, blend widgets, the Rotate All, Space and Move All widgets and type on a path's brackets, then the // bounding-box handles of the current selection. if let Some(c) = CornerDrag::hit(cx, ev, false) { self.state = State::Corner(c); @@ -1546,6 +1580,10 @@ impl SelectionTool { self.state = State::Pie(d); return Some(vec![]); } + if let Some(d) = BlendWidgetDrag::hit(cx, ev) { + self.state = State::BlendWidget(d); + return Some(vec![]); + } if let Some(d) = RotateAllDrag::hit(cx, ev) { self.state = State::RotateAll(d); return Some(vec![]); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 50136f1a9c..f3acf5ddb5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -61,7 +61,7 @@ use outline::KEY_OUTLINE; use outline::{OVERLAY_MAX_ANCHORS, c32, node_outline, walk_drawn}; #[cfg(test)] use overlays::selected_anchor; -use overlays::{HandleLook, draw_inside_overlay, draw_overlays, hover_highlight, ime_geometry, selection_overlay}; +use overlays::{HandleLook, blend_widgets_overlay, draw_inside_overlay, draw_overlays, hover_highlight, ime_geometry, selection_overlay}; pub use panes::Panes; pub(crate) use panes::{consolidate, pane, tile, tiled_area}; use raster::{changed_pixels, pixel_region, upload}; @@ -1196,6 +1196,9 @@ fn selection_visuals( hidden_chars_overlay(app, painter, xf); } } + if !revolve_edit { + blend_widgets_overlay(app, painter, xf); + } // Edit › Spelling › Auto Spell Check: shown with the edges hidden too, as it marks the type // rather than the selection. if app.ui.view.auto_spell_check && !revolve_edit { diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 8ff6faaf79..b00db601a0 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -238,6 +238,26 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: rotate_all_widget(p, xf, tool, &st.doc, &st.selection); } +/// The Appearance and Easing widgets of one selected blend (Selection and Blend tools), each joined +/// by a line to the spine's halfway point: the Appearance widget hollow above it, the Easing widget +/// filled below it. Drawn with the edges hidden too, as they are controls. +pub(super) fn blend_widgets_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { + use vectorcraft_tools::blendwidget::{BlendControl, BlendWidget, RADIUS_PX}; + if !matches!(app.session.tool_id(), "selection" | "blend") { + return; + } + let Some(st) = app.session.active() else { return }; + // The screen's up in the document, with the view turned by `xf.rot`. + let up = vectorcraft_geom::Vec2::new(-xf.rot.sin(), -xf.rot.cos()); + for w in BlendWidget::of(&st.doc, &st.selection, xf.zoom, up) { + let (on, c, color) = (xf.to_screen(w.on), xf.to_screen(w.at), c32(st.doc.layer_color(w.id))); + p.line_segment([on, c], Stroke::new(1.0, color)); + let fill = if w.control == BlendControl::Easing { color } else { Color32::WHITE }; + p.circle_filled(c, RADIUS_PX, fill); + p.circle_stroke(c, RADIUS_PX, Stroke::new(1.0, color)); + } +} + /// The Rotate All, Space and Move All widgets (Selection and Objects on Path tools, one Objects on Path /// group selected). The Rotate All widget is joined by a line to the first object's attachment /// point. It stays while dragged. diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 57c345505a..1097520565 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1981,6 +1981,7 @@ font-technology Variable Променливи Move Slices Преместване на резени Move Spine Point Преместване на точка от оста Reshape Spine Segment Промяна на формата на сегмент от оста + Blend Easing Плавност на преливането Move Brush Преместване на четка Move Swatch Преместване на образец Move Up Преместване нагоре diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index e28312f750..40ffada862 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -3526,6 +3526,7 @@ theme Appearance Izgled Move Mesh Points Pomjeri tačke mreže Move Spine Point Pomjeri tačku kičme Reshape Spine Segment Preoblikuj segment kičme + Blend Easing Ublažavanje stapanja Reset with Mesh Vrati mrežom Reset with Warp Vrati iskrivljenjem Set Mesh Point Color Postavi boju tačke mreže diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 21ac28a103..ba61f3b658 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3563,6 +3563,7 @@ digits Malayalam Malaiàlam Reshape Segment Remodela el segment Reshape Spine Remodela l’espina Reshape Spine Segment Remodela el segment de l’espina + Blend Easing Suavitzat de la fusió Reshape Tool Eina de remodelació Reshape Type Area Remodela l’àrea de text Resize Redimensiona diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 536e373533..0d9d0fc7a1 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3576,6 +3576,7 @@ theme Appearance Motiv Move Mesh Points Přesunout body sítě Move Spine Point Přesunout bod páteře Reshape Spine Segment Přetvarovat segment páteře + Blend Easing Náběh prolnutí Reset with Mesh Obnovit sítí Reset with Warp Obnovit pokřivením Set Mesh Point Color Nastavit barvu bodu sítě diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 64c8e60dad..1427d16f70 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3313,6 +3313,7 @@ theme Appearance Udseende Move Mesh Points Flyt meshpunkter Move Spine Point Flyt rygradpunkt Reshape Spine Segment Omform rygradsegment + Blend Easing Blandingens acceleration Reset with Mesh Nulstil med mesh Reset with Warp Nulstil med forvrængning Set Mesh Point Color Angiv farve for meshpunkt diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index c5604d4a6d..47ef33118e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2057,6 +2057,7 @@ file-filter Templates Vorlagen Move Slices Slices verschieben Move Spine Point Achsenpunkt verschieben Reshape Spine Segment Achsensegment umformen + Blend Easing Beschleunigung der Angleichung Move Brush Pinsel verschieben Move Swatch Farbfeld verschieben Move Up Nach oben verschieben diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 7663ef5045..f761717b3c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2887,6 +2887,7 @@ theme Appearance Εμφάνιση Move Slices Μετακίνηση τομών Move Spine Point Μετακίνηση σημείου κεντρικής καμπύλης Reshape Spine Segment Αναδιαμόρφωση τμήματος κεντρικής καμπύλης + Blend Easing Επιτάχυνση ανάμειξης Move Brush Μετακίνηση πινέλου Move Swatch Μετακίνηση δείγματος χρώματος Move Up Μετακίνηση επάνω diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2e7a2baed1..a5852cd370 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2051,6 +2051,7 @@ font-technology Variable Variables Move Slices Mover sectores Move Spine Point Mover punto del eje Reshape Spine Segment Remodelar segmento del eje + Blend Easing Suavizado de fusión Move Brush Mover pincel Move Swatch Mover muestra Move Up Subir diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 2967ba4320..6d9750ac23 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3514,6 +3514,7 @@ theme Appearance Ulkoasu Move Mesh Points Siirrä verkkopisteitä Move Spine Point Siirrä selkärangan pistettä Reshape Spine Segment Muotoile selkärangan segmentti uudelleen + Blend Easing Sulautuksen pehmennys Reset with Mesh Palauta verkolla Reset with Warp Palauta vääristyksellä Set Mesh Point Color Aseta verkkopisteen väri diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 187b0a89a1..ef1cc3a6a4 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2045,6 +2045,7 @@ font-technology Variable Variables Move Slices Déplacer les tranches Move Spine Point Déplacer le point de la dorsale Reshape Spine Segment Modeler le segment de la dorsale + Blend Easing Lissage du dégradé de formes Move Brush Déplacer la forme Move Swatch Déplacer la nuance Move Up Monter diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 4eb6f4f69e..c1fe72884f 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3884,6 +3884,7 @@ antiAlias Sharp शार्प Reshape Segment सेगमेंट को आकार देना Reshape Spine स्पाइन का आकार बदलें Reshape Spine Segment स्पिन सेगमेंट को फिर से आकार दें + Blend Easing ब्लेंड ईज़िंग Reshape Tool रीशेप टूल Reshape Type Area रीशैप टाइप एरिया Reshape Type Path टाइप पाथ रीशेप करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c60668d353..8c9a9a7c8f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3430,6 +3430,7 @@ mark-style Japanese Japán Reshape Átformálás Reshape Segment Szakasz átformálása Reshape Spine Segment Gerincszakasz átformálása + Blend Easing Összemosás gyorsítása Reshape Tool Átformálás Reshape Type Area Szövegterület átformálása Resize Átméretezés diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 2be4052fc1..d30c9a39b0 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3313,6 +3313,7 @@ theme Appearance Tampilan Move Mesh Points Pindahkan titik-titik mesh Move Spine Point Pindahkan titik spine Reshape Spine Segment Bentuk ulang segmen spine + Blend Easing Easing blend Reset with Mesh Atur ulang dengan mesh Reset with Warp Atur ulang dengan warp Set Mesh Point Color Tetapkan warna titik mesh diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 9711fbd158..997cbec145 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2089,6 +2089,7 @@ file-filter Templates Modelli Move Slices Sposta sezioni Move Spine Point Sposta punto dorsale Reshape Spine Segment Rimodella segmento della dorsale + Blend Easing Andamento fusione Move Brush Sposta pennello Move Swatch Sposta campione Move Up Sposta su diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 571df35191..b89f0b6046 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2298,6 +2298,7 @@ antiAlias Sharp シャープ Move Slices スライスを移動 Move Spine Point スパインポイントを移動 Reshape Spine Segment スパインセグメントをシェイプ変形 + Blend Easing ブレンドのイージング Move Brush ブラシを移動 Move Swatch スウォッチを移動 Move Up 上へ移動 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 98281d92d9..cd1de08b8f 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3156,6 +3156,7 @@ theme Appearance იერსახე Move Mesh Points ბადის წერტილების გადაადგილება Move Spine Point ხერხემლის წერტილის გადაადგილება Reshape Spine Segment ხერხემლის სეგმენტის ფორმის შეცვლა + Blend Easing გადასვლის შერბილება Reset with Mesh ბადით ჩამოყრა Reset with Warp გაღუნვით ხელახლა დაყენება Set Mesh Point Color ბადის წერტილის ფერის დაყენება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index cad90f44ed..d37c9ce6ac 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2288,6 +2288,7 @@ antiAlias Sharp 날카롭게 Move Slices 슬라이스 이동 Move Spine Point 스파인 포인트 이동 Reshape Spine Segment 스파인 세그먼트 모양 변경 + Blend Easing 블렌드 이징 Move Brush 브러시 이동 Move Swatch 견본 이동 Move Up 위로 이동 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 9d71bc2932..76fd327220 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3327,6 +3327,7 @@ theme Appearance Vormgeving Move Mesh Points Maaspunten verplaatsen Move Spine Point Aspunt verplaatsen Reshape Spine Segment Segment van de as opnieuw vormgeven + Blend Easing Blend-easing Reset with Mesh Opnieuw instellen met maas Reset with Warp Opnieuw instellen met vervorming Set Mesh Point Color Kleur van maaspunt instellen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 1e4ff15acb..a4057609a3 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3455,6 +3455,7 @@ theme Appearance Wygląd Move Mesh Points Przesuń punkty siatki Move Spine Point Przesuń punkt grzbietu Reshape Spine Segment Zmień kształt segmentu grzbietu + Blend Easing Wygładzanie przejścia Reset with Mesh Resetuj z siatką Reset with Warp Resetuj z wypaczeniem Set Mesh Point Color Ustaw kolor punktu siatki diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 028582361f..1ecb16082b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1735,6 +1735,7 @@ antiAlias Sharp Nítido Move Slices Mover Fatias Move Spine Point Mover Ponto de Coluna Reshape Spine Segment Remodelar Segmento da Coluna + Blend Easing Suavização da Mesclagem Move Brush Mover Pincel Move Swatch Mover Amostra Move Up Mover para Cima diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 342d9884e7..03a52086ec 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2078,6 +2078,7 @@ file-filter Templates Шаблоны Move Slices Переместить фрагменты Move Spine Point Переместить точку остова Reshape Spine Segment Изменить форму сегмента остова + Blend Easing Сглаживание перехода Move Brush Переместить кисть Move Swatch Переместить образец Move Up Переместить вверх diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index cca9d052cf..a274507c98 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1992,6 +1992,7 @@ font-technology Variable Variabla Move Slices Flytta skivor Move Spine Point Flytta ryggradspunkt Reshape Spine Segment Omforma ryggradssegment + Blend Easing Blandningens acceleration Move Brush Flytta pensel Move Swatch Flytta färgruta Move Up Flytta upp diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 3a2177b404..f0321ad326 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1881,6 +1881,7 @@ font-technology Variable Değişken Move Slices Dilimleri Taşı Move Spine Point Omurga Noktasını Taşı Reshape Spine Segment Omurga Segmentini Yeniden Şekillendirin + Blend Easing Karışım Yumuşatma Move Swatch Renk Örneğini Taşı Move Up Yukarı Taşı Move… Taşı… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 67df483102..471a3cf7a1 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2083,6 +2083,7 @@ file-filter Templates Шаблони Move Slices Перемістити фрагменти Move Spine Point Перемістити точку осі Reshape Spine Segment Змінити форму сегмента осі + Blend Easing Згладжування переходу Move Brush Перемістити пензель Move Swatch Перемістити зразок Move Up Перемістити вгору diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 174b7c7dce..274dfd3b1d 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3199,6 +3199,7 @@ mark-style Japanese Kiểu Nhật Reshape Định hình lại Reshape Segment Định hình lại đoạn Reshape Spine Segment Định hình lại đoạn trục + Blend Easing Gia tốc hòa trộn Reshape Tool Công cụ Định hình lại Reshape Type Area Định hình lại vùng chữ Resize Thay đổi kích thước diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 515600d033..0110916f03 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1945,6 +1945,7 @@ file-filter Templates 模板 Move Slices 移动切片 Move Spine Point 移动脊线点 Reshape Spine Segment 整形脊线线段 + Blend Easing 渐变缓动 @msg select a blend or give `id` 请选择一个混合对象,或给出 `id` @msg not a blend 不是混合对象 @msg the blend has no spine 混合对象没有脊线 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 31472f0d4b..e020bc6ddd 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3032,6 +3032,7 @@ file-filter Templates 範本 Move Slices 移動切片 Move Spine Point 移動脊線點 Reshape Spine Segment 改變脊線線段外框 + Blend Easing 漸變緩動 @msg select a blend or give `id` 請選取漸變物件,或提供 `id` @msg not a blend 不是漸變物件 @msg the blend has no spine 漸變物件沒有脊線 diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index df87187a65..6e67b1493b 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -723,8 +723,20 @@ fn busy_tool_keys(app: &mut VectorcraftApp, ctx: &egui::Context, view: ViewInfo) // The arrows while dragging: a polygon's sides, a grid's rows (↑/↓) and columns (←/→)… let arrows = [(Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down), (Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right)]; for (k, tk) in arrows { + // The press's Shift and Alt (the consume below takes those presses too) go to a tool that + // claims the arrow (Shift steps a blend widget by ten); other tools get the plain arrow. + let pressed = ctx.input(|i| { + i.events.iter().find_map(|e| match e { + egui::Event::Key { key, pressed: true, modifiers, .. } if *key == k && !(modifiers.command || modifiers.ctrl) => Some(*modifiers), + _ => None, + }) + }); if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)) { - let _ = app.session.tool_key(tk, Mods::default(), view); + let mods = match pressed { + Some(m) if app.session.tool_claims_key(tk, view) => crate::canvas::mods(m, false), + _ => Mods::default(), + }; + let _ = app.session.tool_key(tk, mods, view); } } // Digits (5 while dragging with the Perspective Selection tool), once per press. diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 44d438b73b..fd4603162f 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -2728,6 +2728,38 @@ fn a_plain_up_nudges_unless_a_corner_widget_is_pressed() { assert_eq!(kind2, CornerKind::InvertedRound, "Up stepped the kind"); } +/// While a blend widget is pressed, an arrow steps its value by one and Shift with an arrow by ten. +#[test] +fn shift_arrows_step_a_pressed_blend_widget_by_ten() { + use vectorcraft_doc::{NodeId, NodeKind}; + use vectorcraft_tools::blendwidget::BlendWidget; + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let rect = |app: &mut VectorcraftApp, x: f64| { + app.session.execute("shape.rectangle", &json!({"x": x, "y": 190, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap() + }; + let (a, b) = (rect(&mut app, 90.0), rect(&mut app, 290.0)); + app.session.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let id = NodeId(app.session.execute("object.blend.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + app.select_tool("selection"); + app.session.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let shift = |app: &VectorcraftApp| { + let NodeKind::Blend { spec, .. } = &app.session.active().unwrap().doc.node(id).unwrap().kind else { panic!("not a blend") }; + spec.color_shift + }; + let view = app.view_info(); + let st = app.session.active().unwrap(); + let at = BlendWidget::of(&st.doc, &st.selection, view.zoom, vectorcraft_geom::Vec2::new(0.0, -1.0))[0].at; + app.session.pointer(&PointerEvent::new(PointerKind::Down, at.x, at.y), view).unwrap(); + frame(&mut app, press(Key::ArrowUp, Modifiers::SHIFT)); + assert_eq!(shift(&app), 10.0, "Shift+Up steps by ten"); + frame(&mut app, press(Key::ArrowUp, Modifiers::NONE)); + assert_eq!(shift(&app), 11.0, "Up steps by one"); + app.session.pointer(&PointerEvent::new(PointerKind::Up, at.x, at.y), view).unwrap(); + assert_eq!(shift(&app), 11.0); +} + /// #5218: the status bar navigator's four buttons are commands of their own, listed in Edit › /// Keyboard Shortcuts without a default key, so a user can give them one (Page Up and Page Down /// here) and step through the artboards from the keyboard. diff --git a/docs/mcp.md b/docs/mcp.md index aeed51157a..7053f0ede9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8626,6 +8626,23 @@ so its point at `t` moves by (dx, dy), turning a straight spine into a curve (`p {"name":"run_command","arguments":{"command":"object.blend.spine.moveAnchor","params":{"id":40,"anchor":1,"x":150,"y":20}}} ``` +`object.blend.setEasing {id?, easingType?, ramp?, colorShift?}` sets one blend's easing and color shift (the +first selected blend without `id`) and returns `{id, easingType, ramp, colorShift, changed}`; omitted values are kept. +`ramp` is 0 to 100 and `colorShift` -100 to 100. `easingType` and `ramp` need a blend whose steps follow a spine (a +replaced or edited spine, or `orientation: "path"`) and not made without one (`createSpine: "never"`), as the Blend +panel's Type and Ramp do. A value out of range, a hidden or locked blend and a target that is not a blend are errors, +and a call that changes nothing records no undo step (`changed: false`). With one blend selected that has a spine, +the Selection and Blend tools show the same two values as widgets +16 px above and below the spine's halfway point (where the boxes of three or more other visible objects hold that +spot, a widget moves out in 16 px steps, up to 144 px, joined to the halfway point by a line): the upper one sets +`colorShift` and the lower one `ramp`, by one per screen pixel dragged along the spine and by one per arrow key +during the drag (ten with Shift). Each drag is one undo step, and `press_key` Escape or a switch to another tool +during it puts the value back: + +```json +{"name":"run_command","arguments":{"command":"object.blend.setEasing","params":{"id":40,"ramp":35}}} +``` + How blends interpolate: closed shapes start where they twist least unless `starts` picks the anchors; strokes interpolate weight, dashes (a solid stroke counts as the dashed one with its gaps closed), miter limit, arrowhead scale and width profiles, while caps, joins, alignment, arrowheads and brushes switch halfway; gradients of one kind with diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5e0cf03a3a..44f03c5a55 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1680,6 +1680,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Blend, mesh and envelope points by number:** `object.blend.spine.moveAnchor`, `reshapeSegment` and `removeAnchor`, `object.mesh.setPointColor`, `movePoint` and `deletePoint`, and `object.envelope.setMeshPoint` refuse an `anchor`, `segment` or `index` that is not a whole number, and `object.envelope.setMeshPoint` refuses a negative `index`. Specified Steps (`object.blend.make`, `object.blend.options`) and the rows and columns of `object.mesh.create`, `object.envelope.makeWithMesh` and `resetWithMesh` must be whole numbers too. +- **Blend widgets on the canvas:** with one blend selected, the Selection and Blend tools show two widgets 16 px above and below the spine's halfway point (further out, joined to that point by a line, where the boxes of three or more other objects crowd the spot), as the [Blend panel help page](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html) describes an upper widget for Appearance and a lower one for Easing. The upper one sets Shift and the lower one Ramp, keeping the Type, and the lower one shows only when the steps follow a spine. A drag along the spine changes the value by one per screen pixel from its value at the press, the arrow keys during the drag by one (Shift: ten), and Escape or a tool switch cancels it. A drag is one undo step, or none when the value ends where it started. The placement, the drag and the keys are VectorCraft's own rules. `object.blend.setEasing` sets the same values for agents. Evidence: `blendwidget::tests`, `tests::blendopts::{set_easing_edits_one_blend_and_refuses_what_it_cannot_set, dragging_a_blend_widget_is_one_undo_step_and_a_click_or_escape_none}`, `shortcuts::tests::shift_arrows_step_a_pressed_blend_widget_by_ten`. + - **Blend, mesh and envelope numbers given as text:** `object.blend.make` and `object.blend.options` (`steps`, `distance`, `value`), `object.envelope.makeWithWarp`, `resetWithWarp` and `options` (`bend`, `h`, `v`, `horizontalDistortion`, `verticalDistortion`), `object.envelope.makeWithMesh` and `resetWithMesh` (`rows`, `cols`, `columns`), `object.envelope.options` (`fidelity`), `object.mesh.create` (`rows`, `cols`, `columns`, `highlight`) and `object.mesh.setPointColor` (`opacity`) refuse a value other than a number or null, such as `"8"`, and change nothing. `object.mesh.create` refuses an `at` that is not `[x, y]`. - **Blend and envelope options given as text:** `object.blend.make` and `object.blend.options` (`smooth`), `object.blend.spine.moveAnchor` (`independent`) and `reshapeSegment` (`perpendicular`), `object.envelope.makeWithWarp`, `resetWithWarp` and `options` (`horizontal`), `object.envelope.resetWithMesh` (`maintainShape`), `object.envelope.options` (`antiAlias`, `distortAppearance`, `distortLinearGradients`, `distortPatternFills`) and `object.envelope.editContents` (`editing`) refuse a value other than true, false or null, such as `"false"`, and leave the document and its undo history as they were. From 18fe6ad6fd33eb55f024a30f076ab24339873864 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 00:37:35 -0800 Subject: [PATCH 09/92] Automation: ui.dialog.set cannot open Save Preset in Save PDF or Print `__savePresetAs` makes OK save a preset instead of writing the PDF or printing. ui.dialog.set now accepts it only while Save Preset... has added the field, so a request cannot add it and confirm in one step. --- crates/ui-egui/src/dialogs.rs | 17 +++++++++++--- crates/ui-egui/src/tests/automation.rs | 31 ++++++++++++++++++++++++++ docs/control-protocol.md | 2 +- 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9274bbd40c..838fc8161d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -528,10 +528,21 @@ fn hidden_controls(d: &Dialog) -> &'static [&'static str] { } } +/// The controls of [`hidden_controls`] that a button adds to the dialog, which `ui.dialog.set` can +/// set only while the dialog holds them: Save PDF's and Print's `__savePresetAs`, whose presence +/// makes OK save a preset instead of writing the PDF or printing. +const ADDED_CONTROLS: &[&str] = &["__savePresetAs"]; + +/// Whether `field` is a control of `d` that `ui.dialog.set` can set: one of the controls of `d`'s +/// kind ([`hidden_controls`]), and for one that a button adds ([`ADDED_CONTROLS`]), one `d` holds. +fn settable_control(d: &Dialog, field: &str) -> bool { + hidden_controls(d).contains(&field) && (!ADDED_CONTROLS.contains(&field) || d.fields.contains_key(field)) +} + /// Whether `field` is a `__` field of `d` that `ui.dialog.set` refuses: any `__` field, held or -/// not, except the controls of `d`'s kind ([`hidden_controls`]). +/// not, except the controls of `d`'s kind ([`settable_control`]). pub fn protected_field(d: &Dialog, field: &str) -> bool { - field.starts_with("__") && !hidden_controls(d).contains(&field) + field.starts_with("__") && !settable_control(d, field) } /// Whether `ui.dialog.set` can set `field` on `d`: a `__` field only when it is one of the controls @@ -581,7 +592,7 @@ pub fn set_field(d: &mut Dialog, field: String, value: Value) { pub fn field_names(d: &Dialog) -> Vec { let spec = spec(&d.kind); let mut names: Vec = d.fields.keys().filter(|k| !k.starts_with("__")).cloned().collect(); - names.extend(hidden_controls(d).iter().map(|f| f.to_string())); + names.extend(hidden_controls(d).iter().filter(|f| settable_control(d, f)).map(|f| f.to_string())); names.extend(spec.discard.map(|_| "discard".to_string())); names.extend(spec.preview.then(|| "preview".to_string())); names.extend((spec.inputs)(&d.kind).iter().map(|i| i.to_string())); diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index d2d0a8c4b6..36908ec3e6 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -886,6 +886,37 @@ fn dialog_set_allows_hidden_controls() { assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("__search")), Some("grid".to_string())); } +/// Save PDF's and Print's OK save a preset while Save Preset… asks for its name: `ui.dialog.set` +/// can't add `__savePresetAs` to turn OK into saving a preset, alone or with `action: "confirm"`, +/// and can type the name once Save Preset… has added the field. +#[test] +fn dialog_set_cannot_open_save_preset() { + let (mut app, tx) = controlled(); + let saved = |app: &VectorcraftApp| (app.session.prefs.pdf_presets.len(), app.session.prefs.print_presets.len()); + for (open, kind) in [("ui.savePdfDialog", "savePdf"), ("file.print", "print")] { + app.run(open, json!({})).unwrap(); + let presets = saved(&app); + for set in [ + json!({"field": "__savePresetAs", "value": "Proofs"}), + json!({"fields": [{"field": "__savePresetAs", "value": "Proofs"}], "action": "confirm"}), + ] { + let before = dialog_fields(&app); + let r = request(&mut app, &tx, "ui.dialog.set", set.clone()); + assert_eq!(r["error"], format!("the `{kind}` dialog cannot set protected field `__savePresetAs`"), "{set}: {r}"); + assert_eq!(dialog_fields(&app), before, "{set}: no field changes and the dialog stays open"); + } + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "nosuch", "value": true})); + assert!(!r["error"].as_str().unwrap_or_default().contains("__savePresetAs"), "{kind}: {r}"); + assert_eq!(saved(&app), presets, "{kind}: no preset saved"); + // Save Preset… adds the field; the name can then be typed and OK saves the preset. + app.ui.dialog.as_mut().unwrap().fields.insert("__savePresetAs".into(), json!("")); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__savePresetAs", "value": "Proofs", "action": "confirm"})); + assert_eq!(r["result"]["result"]["name"], "Proofs", "{kind}: {r}"); + app.ui.dialog = None; + } + assert_eq!(saved(&app), (1, 1)); +} + /// The refusal of an OK on an install question automation asked. const ASKED: &str = "Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself."; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0fdfbc8231..53ce5d35f9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -102,7 +102,7 @@ the two can still win that race. | `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 | | `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls, Cmd-wheel (Ctrl on Windows and Linux) scrolls sideways and Alt-wheel (Option on the Mac) zooms about the pointer (with the `ctrlWheelScrollsSideways` preference off, Cmd-wheel zooms too); with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it. Over the canvas, the wheel preferences in `docs/mcp.md` change what these keys do | | `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?, imageTracePreview?}` | `outline`: View › Outline for the active document only (each document has its own, a document opens in Preview, and `ui.inspect` shows it as `view.outline`; an error without a document). `imageTracePreview`: the Image Trace panel's Preview (`ui.inspect` shows it as `ui.image_trace_preview`) | -| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value, action?}` or `{fields: [{field, value}…], action?}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`) other than its `__` fields, the `__` fields that are controls of the dialog (such as `__section` and `__allArtboards` in Save PDF, `__constrain` in Transform Each and the shape dialogs, and `__search` and `__category` in Preferences), `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was. Any other `__` field, held or not, holds what the dialog's OK acts on (such as the `__command` and `__params` of a `confirm` or `command` dialog) or the dialog's own state: setting it is the error "the `` dialog cannot set protected field ``", and the dialog stays as it was. `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes. `action: "confirm"` or `"cancel"` on `ui.dialog.set` then confirms or cancels the same dialog in the same request, as `ui.dialog.confirm` and `ui.dialog.cancel` do, and the reply is `{dialog, result}`: the dialog after the fields were set and the reply to confirm (`null` for cancel); any other `action` is an error and no field changes | +| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value, action?}` or `{fields: [{field, value}…], action?}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`) other than its `__` fields, the `__` fields that are controls of the dialog (such as `__section` and `__allArtboards` in Save PDF, `__constrain` in Transform Each and the shape dialogs, and `__search` and `__category` in Preferences; Save PDF's and Print's `__savePresetAs`, the preset name, only while Save Preset… asks for it, since OK then saves a preset), `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was. Any other `__` field, held or not, holds what the dialog's OK acts on (such as the `__command` and `__params` of a `confirm` or `command` dialog) or the dialog's own state: setting it is the error "the `` dialog cannot set protected field ``", and the dialog stays as it was. `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes. `action: "confirm"` or `"cancel"` on `ui.dialog.set` then confirms or cancels the same dialog in the same request, as `ui.dialog.confirm` and `ui.dialog.cancel` do, and the reply is `{dialog, result}`: the dialog after the fields were set and the reply to confirm (`null` for cancel); any other `action` is an error and no field changes | | `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. The inline PNG is capped at 64 MiB: with a `path` the file is still written and `pngBase64` is omitted when the image is larger, while a path-less `data: true` request fails if either the raw RGBA image or the encoded PNG exceeds the cap. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error | | `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | From e7f260184755da69d2372d2907fc39c9cf53c2f6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 00:54:15 -0800 Subject: [PATCH 10/92] Blend: arrow keys step from the widget's shown value; a double-click on a widget leaves the art alone - After a drag past either end of the range, the first arrow key now steps back from the value shown instead of doing nothing. - With the Selection tool, a plain double-click on a blend widget no longer edits or isolates the artwork under it, or leaves isolation. Ctrl/Cmd+Shift double-click still releases a guide there. - docs/mcp.md says the lower widget shows only when the steps follow the spine, and the G13 blend easing entry in docs/gaps.md no longer lists on-canvas easing handles as missing. --- crates/tools/src/blendwidget.rs | 40 +++++++++++++++++++++++++++++---- crates/tools/src/select.rs | 5 +++++ docs/gaps.md | 5 +++-- docs/mcp.md | 4 ++-- 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/crates/tools/src/blendwidget.rs b/crates/tools/src/blendwidget.rs index 634e2639a1..af1194d78b 100644 --- a/crates/tools/src/blendwidget.rs +++ b/crates/tools/src/blendwidget.rs @@ -239,11 +239,11 @@ impl BlendWidgetDrag { /// An arrow key during the drag: one point more (`more`) or less, ten with Shift. pub fn step(&mut self, cx: &ToolContext, more: bool, big: bool) -> Vec { let by = if big { 10.0 } else { 1.0 }; - self.keys += if more { by } else { -by }; - // Past either end, the keys stop adding: the way back starts at once. + // The step goes from the value shown, so past either end (by keys or by the drag) the way + // back starts at once. let (lo, hi) = self.widget.control.range(); - let free = self.dragged(cx); - self.keys = self.keys.clamp(lo - free, hi - free); + let stepped = (self.value_now(cx) + if more { by } else { -by }).clamp(lo, hi); + self.keys = stepped - self.dragged(cx); self.preview(cx) } @@ -533,4 +533,36 @@ mod tests { assert!(!t.busy() && !t.transforming()); assert!(t.pointer(&c, &ev(PointerKind::Up, at)).is_empty(), "the release commits nothing"); } + + /// A double-click on a widget with the Selection tool does nothing to the artwork under it. + #[test] + fn a_double_click_on_a_widget_leaves_the_artwork_under_it_alone() { + let (mut d, _, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased()); + cover(&mut d, 190.0, 206.0, 210.0, 226.0); + let p = paint(); + let c = cx(&d, &s, &p); + let at = Point::new(200.0, 216.0); + assert!(over_widget(&c, at)); + let mut t = SelectionTool::default(); + assert!(t.pointer(&c, &ev(PointerKind::DoubleClick, at)).is_empty()); + // Beside the widget, the double-click reaches the artwork. + assert!(!t.pointer(&c, &ev(PointerKind::DoubleClick, Point::new(192.0, 224.0))).is_empty()); + } + + /// After a drag past either end, the first arrow key steps back from the value shown. + #[test] + fn an_arrow_key_after_a_drag_past_an_end_steps_from_the_value_shown() { + let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased()); + let p = paint(); + let c = cx(&d, &s, &p); + let at = Point::new(200.0, 216.0); + let ramp = |v: f64| vec![Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": v}))]; + for (moved, back, shown) in [(200.0, ToolKey::Left, 99.0), (-200.0, ToolKey::Right, 1.0)] { + let mut t = SelectionTool::default(); + t.pointer(&c, &ev(PointerKind::Down, at)); + t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(moved, 0.0))); + assert_eq!(t.key(&c, back, Mods::default()), ramp(shown), "{moved}"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, at + Vec2::new(moved, 0.0))), vec![Action::Commit]); + } + } } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 84a10d350f..a0c7e43d48 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -1531,6 +1531,11 @@ impl SelectionTool { if let Some(a) = release_guide_at(cx, p, m) { return vec![a]; } + // A selected blend's widget takes a plain double-click: the artwork under it is neither + // edited nor isolated. + if blendwidget::over_widget(cx, p) { + return vec![]; + } if crate::rulerguide::guide_at(cx, p).is_some() { return vec![]; } diff --git a/docs/gaps.md b/docs/gaps.md index 4186fa64dd..1c3b39a664 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -534,8 +534,9 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi (#1676). A linked image is refused, as the public documentation has it. - **Partial:** blend easing (#1124, 30.7): Window › Blend has Type (Ease In, Ease Out, Ease In Out, Ease Out In), Ramp 0–100% and independent color Shift; easing is disabled without a spine. - The original polynomial distributions approximate the public description. On-canvas easing handles - and their synchronization remain missing. [Public source](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html). + The original polynomial distributions approximate the public description. With the Selection or + Blend tool, on-canvas widgets set the color Shift and, when the steps follow a spine, the Ramp; the + Blend panel shows the values they set. [Public source](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html). - **Done:** gradient Dither (30.0): the Gradient panel's Dither box (`paint.editGradient` `dither`, also in `paint.setFill`'s `gradient`, swatches and the native file) draws a linear or radial gradient fill as pixels with seeded noise of up to a diff --git a/docs/mcp.md b/docs/mcp.md index 7053f0ede9..80fd96edc6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8632,10 +8632,10 @@ first selected blend without `id`) and returns `{id, easingType, ramp, colorShif replaced or edited spine, or `orientation: "path"`) and not made without one (`createSpine: "never"`), as the Blend panel's Type and Ramp do. A value out of range, a hidden or locked blend and a target that is not a blend are errors, and a call that changes nothing records no undo step (`changed: false`). With one blend selected that has a spine, -the Selection and Blend tools show the same two values as widgets +the Selection and Blend tools show the same values as widgets 16 px above and below the spine's halfway point (where the boxes of three or more other visible objects hold that spot, a widget moves out in 16 px steps, up to 144 px, joined to the halfway point by a line): the upper one sets -`colorShift` and the lower one `ramp`, by one per screen pixel dragged along the spine and by one per arrow key +`colorShift` and the lower one, shown only when the steps follow the spine as `ramp` needs, sets `ramp`, by one per screen pixel dragged along the spine and by one per arrow key during the drag (ten with Shift). Each drag is one undo step, and `press_key` Escape or a switch to another tool during it puts the value back: From 8cd40e8216519a1a3d130114394b7faa80f23202 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 01:30:06 -0800 Subject: [PATCH 11/92] Docs: blend widgets show only on a blend with a spine A blend made without a spine shows neither widget; docs/gaps.md and docs/target-app-parity.md now say so next to the Ramp widget's condition. --- docs/gaps.md | 5 +++-- docs/target-app-parity.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index 1c3b39a664..3b76f723c2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -535,8 +535,9 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Partial:** blend easing (#1124, 30.7): Window › Blend has Type (Ease In, Ease Out, Ease In Out, Ease Out In), Ramp 0–100% and independent color Shift; easing is disabled without a spine. The original polynomial distributions approximate the public description. With the Selection or - Blend tool, on-canvas widgets set the color Shift and, when the steps follow a spine, the Ramp; the - Blend panel shows the values they set. [Public source](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html). + Blend tool, on-canvas widgets on a blend with a spine set the color Shift and, when the steps follow + that spine, the Ramp; a blend made without a spine shows neither widget. The Blend panel shows the + values they set. [Public source](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html). - **Done:** gradient Dither (30.0): the Gradient panel's Dither box (`paint.editGradient` `dither`, also in `paint.setFill`'s `gradient`, swatches and the native file) draws a linear or radial gradient fill as pixels with seeded noise of up to a diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 44f03c5a55..ce9125ff02 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1680,7 +1680,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Blend, mesh and envelope points by number:** `object.blend.spine.moveAnchor`, `reshapeSegment` and `removeAnchor`, `object.mesh.setPointColor`, `movePoint` and `deletePoint`, and `object.envelope.setMeshPoint` refuse an `anchor`, `segment` or `index` that is not a whole number, and `object.envelope.setMeshPoint` refuses a negative `index`. Specified Steps (`object.blend.make`, `object.blend.options`) and the rows and columns of `object.mesh.create`, `object.envelope.makeWithMesh` and `resetWithMesh` must be whole numbers too. -- **Blend widgets on the canvas:** with one blend selected, the Selection and Blend tools show two widgets 16 px above and below the spine's halfway point (further out, joined to that point by a line, where the boxes of three or more other objects crowd the spot), as the [Blend panel help page](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html) describes an upper widget for Appearance and a lower one for Easing. The upper one sets Shift and the lower one Ramp, keeping the Type, and the lower one shows only when the steps follow a spine. A drag along the spine changes the value by one per screen pixel from its value at the press, the arrow keys during the drag by one (Shift: ten), and Escape or a tool switch cancels it. A drag is one undo step, or none when the value ends where it started. The placement, the drag and the keys are VectorCraft's own rules. `object.blend.setEasing` sets the same values for agents. Evidence: `blendwidget::tests`, `tests::blendopts::{set_easing_edits_one_blend_and_refuses_what_it_cannot_set, dragging_a_blend_widget_is_one_undo_step_and_a_click_or_escape_none}`, `shortcuts::tests::shift_arrows_step_a_pressed_blend_widget_by_ten`. +- **Blend widgets on the canvas:** with one blend selected that has a spine, the Selection and Blend tools show two widgets 16 px above and below the spine's halfway point (further out, joined to that point by a line, where the boxes of three or more other objects crowd the spot), as the [Blend panel help page](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html) describes an upper widget for Appearance and a lower one for Easing. The upper one sets Shift and the lower one Ramp, keeping the Type, and the lower one shows only when the steps follow that spine; a blend made without a spine shows neither. A drag along the spine changes the value by one per screen pixel from its value at the press, the arrow keys during the drag by one (Shift: ten), and Escape or a tool switch cancels it. A drag is one undo step, or none when the value ends where it started. The placement, the drag and the keys are VectorCraft's own rules. `object.blend.setEasing` sets the same values for agents. Evidence: `blendwidget::tests`, `tests::blendopts::{set_easing_edits_one_blend_and_refuses_what_it_cannot_set, dragging_a_blend_widget_is_one_undo_step_and_a_click_or_escape_none}`, `shortcuts::tests::shift_arrows_step_a_pressed_blend_widget_by_ten`. - **Blend, mesh and envelope numbers given as text:** `object.blend.make` and `object.blend.options` (`steps`, `distance`, `value`), `object.envelope.makeWithWarp`, `resetWithWarp` and `options` (`bend`, `h`, `v`, `horizontalDistortion`, `verticalDistortion`), `object.envelope.makeWithMesh` and `resetWithMesh` (`rows`, `cols`, `columns`), `object.envelope.options` (`fidelity`), `object.mesh.create` (`rows`, `cols`, `columns`, `highlight`) and `object.mesh.setPointColor` (`opacity`) refuse a value other than a number or null, such as `"8"`, and change nothing. `object.mesh.create` refuses an `at` that is not `[x, y]`. From d5572f8a6cd92c783d64c9cf9d2a8a2f56761e02 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 01:36:09 -0800 Subject: [PATCH 12/92] Data Recovery: a keeps- entry in an area the same restore or discard holds keeps nothing Restore and discard hold each crashed app's area until they finish. When a later area's copies were kept by an entry in an area already held, the check took a second lock on that area, found it taken, and counted the area as running, so its copies were listed but neither restored nor discarded. Areas the command already holds now count as gone in that check. --- crates/engine/src/cmd/recovery.rs | 24 ++++++++++++++---------- crates/engine/src/tests/recovery.rs | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index fce76bcc27..6add9cd7be 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -1253,15 +1253,16 @@ enum Owner { /// dropped) and, with `take` in a store without locks, taken over with a fresh heartbeat, so /// another app leaves it alone meanwhile. In a store without locks an area is gone only when /// nothing says its app runs: no announcement (or the store can't tell) and no heartbeat for three -/// intervals but the one this session wrote taking it over. -fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec, take: bool) -> Owner { +/// intervals but the one this session wrote taking it over. `held` names the gone apps' areas the +/// caller already holds. +fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec, take: bool, held: &[&str]) -> Owner { if s.recovery.own.as_ref().is_some_and(|o| o.area == area && o.store.location() == store.location()) { return Owner::Mine; } match store.lock(area) { Ok(Lock::Held(h)) => { // Copies an app left here when it had to leave the area stay its own while it runs. - if let Some(who) = keeper(s, store, area) { + if let Some(who) = keeper(s, store, area, held) { return who; } hold.push(h); @@ -1272,7 +1273,7 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec< if store.announced(area).unwrap_or(false) || beating(store, area, stale_after(s), s.recovery.adopted.get(area).copied()) { return Owner::Running; } - if let Some(who) = keeper(s, store, area) { + if let Some(who) = keeper(s, store, area, held) { return who; } // Best effort: without it another app might offer the copies too, which loses nothing. @@ -1288,12 +1289,14 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec< /// `/keeps-` ([`mark_left`]): this session, or another app that is running. `None`: /// no app that runs keeps them. An entry counts only in an area this account can lock or tell is /// held ([`holder`]), so an entry in a folder of another account, or in one whose lock can't be -/// checked, keeps nothing. A store that can't be listed now keeps them (a copy kept loses nothing). -fn keeper(s: &Session, store: &dyn RecoveryStore, area: &str) -> Option { +/// checked, keeps nothing. So does an entry in one of the gone apps' areas the caller holds +/// (`held`), whose lock this session would otherwise find taken. A store that can't be listed now +/// keeps them (a copy kept loses nothing). +fn keeper(s: &Session, store: &dyn RecoveryStore, area: &str, held: &[&str]) -> Option { let Ok(names) = store.list() else { return Some(Owner::Running) }; let mark = format!("/{KEEPS}{area}"); let mut who = None; - for k in names.iter().filter_map(|n| n.strip_suffix(mark.as_str())).filter(|k| *k != area) { + for k in names.iter().filter_map(|n| n.strip_suffix(mark.as_str())).filter(|k| *k != area && !held.contains(k)) { match holder(s, store, k) { Owner::Mine => return Some(Owner::Mine), Owner::Running => who = Some(Owner::Running), @@ -1355,7 +1358,7 @@ fn list(s: &mut Session, _: &Value) -> Result { let mut rows = vec![]; for area in store.areas().map_err(EngineError::Other)? { let mut hold = vec![]; - let who = owner(s, store.as_ref(), &area, &mut hold, false); + let who = owner(s, store.as_ref(), &area, &mut hold, false, &[]); let files: Vec<&String> = copies.iter().filter(|(a, _)| *a == area).map(|(_, f)| f).collect(); for f in &files { let meta = read_meta(store.as_ref(), f); @@ -1395,10 +1398,11 @@ fn targets(s: &mut Session, store: &dyn RecoveryStore, cmd: &str, p: &Value) -> } let picked = |c: &str| file.is_none_or(|f| f == c) && !left.iter().any(|l| l == c); let areas: BTreeSet<&str> = copies.iter().filter(|(_, c)| picked(c)).map(|(a, _)| a.as_str()).collect(); - let mut out = vec![]; + let mut out: Vec = vec![]; for area in areas { let mut hold = vec![]; - match (owner(s, store, area, &mut hold, true), file) { + let held: Vec<&str> = out.iter().map(|(a, _, _)| a.as_str()).collect(); + match (owner(s, store, area, &mut hold, true, &held), file) { (Owner::Gone, _) => { let files = copies.iter().filter(|(a, c)| a == area && picked(c)).map(|(_, c)| c.clone()).collect(); out.push((area.to_string(), files, hold)); diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index 8248f12a48..95387b4054 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -1179,6 +1179,31 @@ fn a_keeps_entry_in_a_running_apps_area_keeps_the_copies() { assert_eq!(offered(&mut b).len(), 2); } +/// An entry `/keeps-` in a crashed app's area keeps nothing while restore or +/// discard holds that area: both crashed apps' copies are restored, and discarded. +#[test] +fn a_keeps_entry_in_a_crashed_apps_area_held_by_the_same_command_keeps_nothing() { + let crashed = |store: &Arc| { + let mut s = session_in(store); + s.execute("file.recovery.save", &json!({})).unwrap(); + s.recovery.own_area().unwrap().to_string() + }; + for cmd in ["file.recovery.restore", "file.recovery.discard"] { + let store = Arc::new(MemoryStore::default()); + let a = crashed(&store); + let c = crashed(&store); + assert_ne!(a, c); + // The area that sorts first, which the command holds first, keeps the other one's copies. + let (first, second) = if a < c { (a, c) } else { (c, a) }; + store.write(&format!("{first}/keeps-{second}"), b"").unwrap(); + let mut b = relaunch(&store); + assert_eq!(offered(&mut b).len(), 2); + let r = b.execute(cmd, &json!({})).unwrap(); + let done = if cmd == "file.recovery.restore" { restored(r) } else { r["discarded"].as_array().unwrap().len() }; + assert_eq!(done, 2, "{cmd}"); + } +} + /// An entry `/keeps-` in a folder whose lock file can't be opened keeps nothing. #[cfg(unix)] #[test] From ba34df9543f4feee27d30f3c8e462d74a49c785c Mon Sep 17 00:00:00 2001 From: jmverges Date: Thu, 12 Nov 2026 10:52:44 +0000 Subject: [PATCH 13/92] .ai import: type filled or stroked with a pattern keeps its pattern (#6856) In the text document of a .ai's editing copy, a character's fill (key 53) or stroke (key 54) is a paint with a colour under key 0 and, under key 5, the name of an art style of the editing data. For a pattern swatch, key 0 is black and the pattern is only in that style: a style of one paint (/SimpleStyle) that paints with `(name) ... [matrix] p`. The reader took key 0 alone, so type painted with a pattern came in black. The art style reader now keeps the pattern of a style that is one paint, and the import places it as a path's pattern fill would be (its matrix in art space; the files' own pages tile such type with it), by the style's name. A run whose fill or stroke is on and names such a style takes the pattern, in the type's text space, so its tiles stay where the document has them. A pattern the file doesn't define or can't place, a key 5 that isn't a string, and a style of any other paint keep the colour as before. A style that also has filters gives no pattern for type, and a pattern that can't be placed in the type's text space keeps the colour. A .ai saved again writes such type as outlines with a warning, as it already does for any type with pattern paint (#5965). Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/ai/read.rs | 4 + crates/eps/src/import/ai/read/document.rs | 12 ++ crates/eps/src/import/ai/read/ops.rs | 41 ++++-- crates/eps/src/import/ai/style.rs | 74 ++++++++--- crates/eps/src/import/ai/style/tests.rs | 15 +++ crates/eps/src/import/ate.rs | 153 ++++++++++++++++++++-- crates/eps/src/import/native/build.rs | 3 +- crates/eps/src/import/tests_native.rs | 65 +++++++++ docs/file-format-parity.md | 8 +- 9 files changed, 335 insertions(+), 40 deletions(-) diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 5a253f21a3..3721ce7960 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -205,6 +205,10 @@ pub struct Structure { pub warped_type: super::envelope::WarpedType, /// The document's graphic styles are those of the file's style list, in place of the defaults. pub styles_read: bool, + /// The patterns that paint the text document's characters, placed in the document, by the + /// name of the art style that gives each: the styles that are one paint, a pattern this read + /// (see `ate::Texts::with_patterns`). + pub text_patterns: HashMap, /// The file's `%AI24_LargeCanvasScale` (1 when it declares none), applied or not. pub canvas_scale: f64, /// The objects by their XML id (`AI10_ArtUID`), a text object by its slot's id: `None` for an diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index ad7f266036..ca943e8817 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -1335,6 +1335,17 @@ impl<'a> Reader<'a> { doc.limit_depth_noting(&mut warnings); } warnings.extend(self.names.notes()); + // The patterns of the styles that are one paint, which the text document's characters + // name for their fills and strokes: those that place in the document. + let text_patterns = self + .styles + .iter() + .filter_map(|(style, st)| { + let Some(crate::import::ai::style::StyleItem::Pattern { name, matrix, controls }) = &st.simple_pattern else { return None }; + let paint = super::ops::style_pattern(name, *matrix, controls, &self.pattern_tiles, &self.v7_patterns, &self.global_names, to_doc)?; + Some((style.clone(), paint)) + }) + .collect(); Ok(Structure { doc, to_doc, @@ -1358,6 +1369,7 @@ impl<'a> Reader<'a> { page, }, styles_read, + text_patterns, canvas_scale: self.canvas_scale, xml_ids: self.xml_ids, bindings, diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index c0d9852d88..82f16c04a9 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -943,6 +943,29 @@ fn pattern_xf(space: Affine, matrix: Option, tile: Rect) -> Option= 1e-12 && xf.as_coeffs().iter().all(|v| v.is_finite())).then_some(xf) } +/// A style's pattern (`StyleItem::Pattern`): the file's pattern `name` placed by `matrix` (its +/// space → art space; `controls` apply first for a pattern defined in the version 7 form, `v7` +/// those patterns' names) and carried into the document by `space`, with the name `names` give +/// it there. `None` when the pattern wasn't read (`tiles`: the tiles of those that were, by their +/// names in the file) or doesn't place. +pub(super) fn style_pattern( + name: &str, + matrix: Option<[f64; 6]>, + controls: &[f64], + tiles: &HashMap, + v7: &HashSet, + names: &ShownNames, + space: Affine, +) -> Option { + let tile = tiles.get(name).copied()?; + let mut matrix = matrix.and_then(|m| paint::affine(&m)); + if v7.contains(name) { + matrix = Some(matrix.unwrap_or(Affine::IDENTITY) * pattern_controls(controls)?); + } + let xf = pattern_xf(space, matrix, tile)?; + Some(Paint::Pattern { pattern: names.get(Global::Pattern, name).to_string(), xf }) +} + /// A pattern fill's `x y scaleX scaleY angle reflect axis shear shearAxis` as one transform of the /// pattern's space, applied before its initial matrix: the shear (by `shear` degrees along the line /// at `shearAxis` degrees), the reflection about the line at `axis` degrees when `reflect` isn't @@ -1147,15 +1170,15 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p } StyleItem::Pattern { name, matrix, controls } => match placed.get(&format!("{PATTERN_KEY}{}", paints.names.get(Global::Pattern, name))) { Some(p) => AppearanceItem::Fill(FillLayer::new(p.clone())), - None => { - let tile = paints.patterns.get(name).copied()?; - let mut matrix = matrix.and_then(|m| paint::affine(&m)); - if paints.v7_patterns.contains(name) { - matrix = Some(matrix.unwrap_or(Affine::IDENTITY) * pattern_controls(controls)?); - } - let xf = pattern_xf(paints.space, matrix, tile)?; - AppearanceItem::Fill(FillLayer::new(Paint::Pattern { pattern: paints.names.get(Global::Pattern, name).to_string(), xf })) - } + None => AppearanceItem::Fill(FillLayer::new(style_pattern( + name, + *matrix, + controls, + paints.patterns, + paints.v7_patterns, + paints.names, + paints.space, + )?)), }, StyleItem::Stroke { brush, .. } => { // A brushed stroke draws as its look only with the brush the Brushes panel keeps diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index f15744f30c..a884fd9296 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -202,6 +202,10 @@ pub(super) struct ArtStyle { pub transparency: Option<(f32, BlendMode, bool, Knockout)>, /// [`Self::effects`] holds Effect › Pathfinder effects, which only a group is read with. pub pathfinder: bool, + /// A style that is one paint (`/SimpleStyle :`, its paint's operators, `/Paint ;`), when that + /// paint is a pattern: its [`StyleItem::Pattern`]. A text document's characters name such + /// styles for their fills and strokes (`ate`). `None` for any other paint or style. + pub simple_pattern: Option, } impl ArtStyle { @@ -406,9 +410,14 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap = vec![]; let mut pending: Option = None; let mut in_basic = false; - let mut finish = |name: Option, filters: &mut Vec| { + // A style that is one paint being read, and its pattern so far. + let mut simple: Option> = None; + let mut finish = |name: Option, filters: &mut Vec, simple_pattern: Option| { if let Some(name) = name { - styles.insert(name, style(filters)); + // Only a style that is one paint holds a pattern for type: one with filters as well + // paints with more than that. + let simple_pattern = simple_pattern.filter(|_| filters.is_empty()); + styles.insert(name, ArtStyle { simple_pattern, ..style(filters) }); } filters.clear(); }; @@ -445,11 +454,12 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap match n { b"KnownStyle" => { - finish(name.take(), &mut filters); + finish(name.take(), &mut filters, simple.take().flatten()); operands.clear(); open.clear(); (pending, in_basic, style_entry, other_entry) = (None, false, None, None); } + b"SimpleStyle" if filters.is_empty() => simple = Some(None), b"FillStyle" => style_entry = Some(Kind::Fill), b"StrokeStyle" | b"BlendStyle" => style_entry = Some(Kind::Other), b"Name" if name.is_none() && filters.is_empty() => name = operands.iter().rev().find_map(|v| names.text(v)), @@ -549,9 +559,7 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap HashMap { - 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(); - <[f64; 6]>::try_from(n.as_slice()).ok().filter(|m| m.iter().all(|v| v.is_finite())) - } - _ => None, - }); - let controls: Vec = - vals.iter().skip_while(|v| !matches!(v, V::Str(_))).skip(1).take_while(|v| !matches!(v, V::Arr(_))).filter_map(V::num).collect(); - if let Some(name) = name { - f.pattern = Some((name, matrix, controls)); + if let Some(pattern) = pattern_operands(vals, names) { + f.pattern = Some(pattern); } } _ => {} } } +/// The operands of `(name) … [matrix] p`: the pattern's name read by `names`, its matrix (six +/// finite numbers) and the numbers between the name and the matrix. `None` without a name. +fn pattern_operands(vals: &[V], names: &mut Names) -> Option<(String, Option<[f64; 6]>, Vec)> { + 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(); + <[f64; 6]>::try_from(n.as_slice()).ok().filter(|m| m.iter().all(|v| v.is_finite())) + } + _ => None, + }); + let controls: Vec = + vals.iter().skip_while(|v| !matches!(v, V::Str(_))).skip(1).take_while(|v| !matches!(v, V::Arr(_))).filter_map(V::num).collect(); + Some((name, matrix, controls)) +} + +/// A paint operator in a style being read: one of the last filter's (`filters`), else one of a style +/// that is one paint (`simple`, while one is read). +fn style_paint_op(filters: &mut [Filter], simple: &mut Option>, op: &str, vals: &[V], names: &mut Names) { + if let Some(f) = filters.last_mut() { + paint_op(f, op, vals, names); + } else if let Some(paint) = simple.as_mut() { + simple_paint_op(paint, op, vals, names); + } +} + +/// A paint operator of a style that is one paint: a pattern (`p`) is kept as its +/// [`StyleItem::Pattern`], and any other paint after it (a colour, a stroke's pattern or a +/// gradient) leaves the style without one. +fn simple_paint_op(paint: &mut Option, op: &str, vals: &[V], names: &mut Names) { + match op { + "p" => *paint = pattern_operands(vals, names).map(|(name, matrix, controls)| StyleItem::Pattern { name, matrix, controls }), + "Xa" | "k" | "g" | "x" | "Xx" | "Xk" | "Xs" | "Xz" | "XA" | "K" | "G" | "X" | "XX" | "XK" | "XS" | "XZ" | "xa" | "xx" | "xk" | "xs" + | "xz" | "P" | "Bg" => *paint = None, + _ => {} + } +} + /// A variable-width stroke's profile: its widths (fractions of the stroke's) at positions along the /// path, the same both sides unless it gives each side's. fn width_profile(arrays: &HashMap>) -> Option { diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index d39a87b5c0..ebe161a251 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -1496,3 +1496,18 @@ fn art_styles_read_registration_and_named_colors() { let [StyleItem::Stroke { paint: Paint::Solid { swatch, tint, .. }, .. }] = st_stroke.items.as_slice() else { panic!("{st_stroke:?}") }; assert_eq!((swatch.as_deref(), *tint), (Some(vectorcraft_color::swatch::REGISTRATION), 0.0)); } + +/// Only a style that is one paint holds a pattern for type: the same pattern paint in a style that +/// also has filters gives none, whichever comes first. +#[test] +fn only_a_style_of_one_pattern_paint_holds_a_pattern_for_type() { + let simple = "/SimpleStyle :\n0 O\n(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n0 1 Xd\n/Paint ;\n"; + let active = "/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n1 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n\ + /FillStyle : 0 O\n1 0 0 0 k\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n;\n"; + let styles = |body: &str| art_styles_in(format!("/KnownStyle :\n(S) /Name ,\n{body} /Def ;\n").as_bytes(), &mut Names::default()); + assert!(styles(simple).get("S").is_some_and(|s| s.simple_pattern.is_some())); + for body in [format!("{simple}{active}"), format!("{active}{simple}")] { + assert!(styles(&body).get("S").is_none_or(|s| s.simple_pattern.is_none()), "{body}"); + } +} diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 194c0ff1e2..efaed14199 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -21,7 +21,7 @@ //! side; the canvas's centre, 8191.5, is the centre of the file's `%AI3_TemplateBox`. A story without //! a layout has its point type's anchor at its frame's point. See [`Story::place`]. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen}; use vectorcraft_color::{Color, Paint}; @@ -461,6 +461,11 @@ struct Style { v_scale: f64, fill: Option, stroke: Option, + /// The pattern that paints the fill or the stroke in place of its colour: the one the art + /// style named by key 5 of its paint gives ([`Texts::with_patterns`]), placed in the document. + /// `None` for any other paint, which keeps its colour. + fill_pattern: Option, + stroke_pattern: Option, stroke_width: f64, /// Key 61, the stroke's cap: 0 butt, 1 round, 2 projecting (square). stroke_cap: LineCap, @@ -491,9 +496,23 @@ pub(super) struct Texts { /// attributes (key 5) and the paragraph style it is based on (key 6, an index into these). para_sheets: Vec, frames: Vec, + /// The patterns that paint characters, by the name of the art style that gives each + /// ([`Self::with_patterns`]). + patterns: HashMap, } impl Texts { + /// The text document with its characters painted by `patterns`: the patterns of the editing + /// data's art styles that paint with one, each placed in the document, by the style's name. + /// A run's fill or stroke (key 53 or 54) is a paint whose key 0 is a colour and whose key 5 + /// names the art style that paints it. In users' files (used locally, never committed), such a + /// style is one paint: a colour, which key 0 already has, or a pattern, whose key 0 is black + /// (`/0 0 /1 [ 1 0 ]`). The pattern's matrix is in art space, as a path's pattern fill has it: + /// the files' own pages tile the type with the matrix a path's fill would get there. + pub(super) fn with_patterns(self, patterns: HashMap) -> Texts { + Texts { patterns, ..self } + } + /// The text document in the editing copy `data`, if it has one that can be read. pub(super) fn read(data: &[u8]) -> Option { fn find(h: &[u8], n: &[u8], from: usize) -> Option { @@ -540,6 +559,7 @@ impl Texts { char_sheets: sheets("5"), para_sheets: sheets("6"), frames: doc.get("0")?.get("8")?.get("0")?.list().to_vec(), + patterns: HashMap::new(), }) } @@ -747,6 +767,8 @@ impl Texts { let on = |k: &str| !matches!(pick(k), Some(Val::Bool(false))); let font = num("0").and_then(|i| self.fonts.get(i as usize)).filter(|f| !f.is_empty()).cloned(); let size = num("1").filter(|v| *v > 0.0 && *v < 1e5).unwrap_or(12.0); + // Key 5 of a paint names the art style that paints it ([`Self::with_patterns`]). + let pattern = |k: &str| pick(k).and_then(|p| p.get("5")).and_then(Val::str).and_then(|name| self.patterns.get(name)).cloned(); Style { font, size, @@ -757,6 +779,8 @@ impl Texts { v_scale: num("7").filter(|v| *v > 0.0 && *v < 100.0).unwrap_or(1.0), fill: if on("56") { Some(pick("53").and_then(paint).unwrap_or(Color::BLACK)) } else { None }, stroke: if matches!(pick("57"), Some(Val::Bool(true))) { pick("54").and_then(paint) } else { None }, + fill_pattern: pattern("53"), + stroke_pattern: pattern("54"), stroke_width: num("63").filter(|v| *v >= 0.0 && *v < 1e4).unwrap_or(1.0), // Numbered as PostScript's `setlinecap` and `setlinejoin` operators number them // (PostScript Language Reference, 3rd ed.): in users' files the page strokes type with @@ -1109,6 +1133,10 @@ 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); + // Patterns stay where the document has them, wherever the type's text space is (`xf`'s + // determinant is 1, so it can be inverted; a pattern that still doesn't map to finite + // numbers keeps its colour, see `char_style`). + let to_text = xf.inverse(); let chars: Vec = text.chars().collect(); let mut runs = vec![]; let mut from = 0usize; @@ -1119,13 +1147,13 @@ impl Story { if piece.is_empty() { continue; } - runs.push(TextRun::new(piece, char_style(st, scale))); + runs.push(TextRun::new(piece, char_style(st, scale, to_text))); } 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))); + runs.push(TextRun::new(chars.get(from..).unwrap_or_default().iter().collect::(), char_style(last, scale, to_text))); } - let first = self.runs.first().map(|r| char_style(&r.1, scale))?; + let first = self.runs.first().map(|r| char_style(&r.1, scale, to_text))?; let mut t = TextObject::point(Point::ZERO, "", first); if !runs.is_empty() { t.runs = runs; @@ -1295,10 +1323,20 @@ 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 { +/// lengths (size, leading, baseline shift, stroke width) scaled by `scale`, and its patterns placed +/// in the type's text space by `to_text` (the document → its text space), where they stay as the +/// document has them. +fn char_style(s: &Style, scale: f64, to_text: Affine) -> 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); + // A fill or stroke that is on takes its pattern in place of its colour. + let paint = |color: Option, pattern: &Option| match (color, pattern) { + // A pattern that can't be placed (the type's text space out of range) keeps the colour. + (Some(_), Some(Paint::Pattern { pattern, xf })) if (to_text * *xf).is_finite() => { + Paint::Pattern { pattern: pattern.clone(), xf: to_text * *xf } + } + (color, _) => color.map_or(Paint::None, Paint::solid), + }; // Type without a stroke keeps the stroke's defaults, as its weight is 0. let (stroke_cap, stroke_join, stroke_miter_limit) = if s.stroke.is_some() { (s.stroke_cap, s.stroke_join, s.stroke_miter_limit) } else { (LineCap::Butt, LineJoin::Miter, 10.0) }; @@ -1314,8 +1352,8 @@ fn char_style(s: &Style, scale: f64) -> CharStyle { baseline_shift: s.baseline_shift * scale, h_scale: s.h_scale * 100.0, v_scale: s.v_scale * 100.0, - fill: s.fill.map_or(Paint::None, Paint::solid), - stroke: s.stroke.map_or(Paint::None, Paint::solid), + fill: paint(s.fill, &s.fill_pattern), + stroke: paint(s.stroke, &s.stroke_pattern), stroke_width: if s.stroke.is_some() { s.stroke_width * scale } else { 0.0 }, stroke_cap, stroke_join, @@ -1350,6 +1388,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let justify = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).justify; // Right to left from the document's defaults, or the paragraph's own. @@ -1373,6 +1412,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; 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); @@ -1395,6 +1435,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; 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 }; @@ -1412,6 +1453,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let own = |own: &[u8]| para_style(&bare.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).hyphenation; assert_eq!(own(b""), d); @@ -1431,6 +1473,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let tabs = |own: &[u8], scale: f64| { para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), scale) @@ -1497,6 +1540,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), } .para(None); let mut s = story(" 80 mm ", &[0.0], ¶); @@ -1544,6 +1588,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: cases.iter().map(|c| frame(c)).collect(), + patterns: HashMap::new(), }; let vertical: Vec = (0..cases.len() + 1).map(|i| texts.frame_vertical(i)).collect(); assert_eq!(vertical, [true, false, false, false, false, false, false, false, false, false, false]); @@ -1561,6 +1606,7 @@ mod tests { char_sheets: vec![read(b"/0 ([Normal Character Style]) /6 << >>").unwrap(), read(b"/0 (Block) /6 << /35 3 >>").unwrap()], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let tcy = |run: &[u8]| texts.style(Some(&read(run).unwrap())).tate_chu_yoko; assert!(tcy(b"/6 << /35 3 >>"), "the run's own"); @@ -1584,6 +1630,7 @@ mod tests { char_sheets: vec![read(b"/0 ([Normal Character Style]) /6 << >>").unwrap(), read(b"/0 (Rotated) /6 << /35 2 >>").unwrap()], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let roman = |defaults: &[u8], run: &[u8]| texts(defaults).style(Some(&read(run).unwrap())).standard_vertical_roman; assert!(roman(b"/35 1", b"/6 << >>"), "the document's defaults"); @@ -1614,9 +1661,10 @@ mod tests { char_sheets: vec![read(b"/0 ([Normal Character Style]) /6 << >>").unwrap(), read(b"/0 (Rounded) /6 << /61 1 /62 1 >>").unwrap()], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let stroke = |run: &[u8]| { - let s = char_style(&texts.style(Some(&read(run).unwrap())), 1.0); + let s = char_style(&texts.style(Some(&read(run).unwrap())), 1.0, Affine::IDENTITY); (s.stroke_cap, s.stroke_join, s.stroke_miter_limit) }; assert_eq!(stroke(b"/6 << /61 2 /62 2 /64 2.5 >>"), (LineCap::Square, LineJoin::Bevel, 2.5), "the run's own"); @@ -1638,6 +1686,88 @@ mod tests { assert_eq!(stroke(b"/5 1 /6 << /57 false >>"), (LineCap::Butt, LineJoin::Miter, 10.0), "no stroke"); } + /// The pattern "Dots", its tile's corner at `at`. + fn dots_at(at: (f64, f64)) -> Paint { + Paint::Pattern { pattern: "Dots".into(), xf: Affine::translate(at) } + } + + /// Texts whose characters may name the art style "Anon 1", which paints with "Dots" at (10, 20) + /// in the document, and "Solid", which isn't a pattern. + fn patterned_texts() -> Texts { + let patterns = HashMap::from([("Anon 1".to_string(), dots_at((10.0, 20.0))), ("Solid".to_string(), Paint::solid(Color::WHITE))]); + Texts { + stories: vec![], + fonts: vec![], + defaults: Val::Dict(BTreeMap::new()), + para_defaults: Val::Dict(BTreeMap::new()), + char_sheets: vec![], + para_sheets: vec![], + frames: vec![], + patterns: HashMap::new(), + } + .with_patterns(patterns) + } + + /// Key 5 of a run's fill or stroke names the art style that paints it: one that paints with a + /// pattern gives the run that pattern in place of the colour of key 0 (black), placed in the + /// type's text space where the document has it. A fill or stroke that is off stays off, and a + /// key 5 that isn't the name of such a style, or isn't a name at all, keeps the colour. + #[test] + fn a_run_painted_by_a_patterns_art_style_takes_the_pattern() { + // GUARDS: type a pattern paints came in black, the colour key 0 keeps for it. + let texts = patterned_texts(); + let black_paint = Paint::solid(Color::gray(1.0)); + let to_text = Affine::translate((-4.0, -5.0)); + let paints = |run: &str| { + let s = char_style(&texts.style(Some(&read(run.as_bytes()).unwrap())), 1.0, to_text); + (s.fill, s.stroke) + }; + let black = |style: &str| format!("<< /99 /CAITextPaint /0 << /0 0 /1 [ 1.0 0.0 ] >> {style} >>"); + let fill = |style: &str| paints(&format!("/6 << /53 {} >>", black(style))); + assert_eq!(fill("/5 (Anon 1)"), (dots_at((6.0, 15.0)), Paint::None)); + let stroke = paints(&format!("/6 << /53 {} /57 true /54 {} >>", black(""), black("/5 (Anon 1)"))); + assert_eq!(stroke, (black_paint.clone(), dots_at((6.0, 15.0)))); + // Off. + assert_eq!(paints(&format!("/6 << /56 false /53 {} >>", black("/5 (Anon 1)"))), (Paint::None, Paint::None)); + assert_eq!(paints(&format!("/6 << /57 false /54 {} >>", black("/5 (Anon 1)"))).1, Paint::None); + // A stroke whose colour can't be read paints nothing, pattern or not, as before. + assert_eq!(paints("/6 << /57 true /54 << /0 << /0 9 /1 [ 1.0 ] >> /5 (Anon 1) >> >>").1, Paint::None); + for other in ["", "/5 (Anon 2)", "/5 (Solid)", "/5 ()", "/5 /Anon", "/5 1", "/5 [ (Anon 1) ]", "/5 << /0 (Anon 1) >>", "/5 true"] { + assert_eq!(fill(other), (black_paint.clone(), Paint::None), "{other}"); + } + // A text space the pattern can't be placed in keeps the colour. + let run = read(format!("/6 << /53 {} >>", black("/5 (Anon 1)")).as_bytes()).unwrap(); + for to_text in [Affine::new([1e308, 0.0, 0.0, 1e308, 0.0, 0.0]), Affine::new([f64::NAN, 0.0, 0.0, 1.0, 0.0, 0.0])] { + assert_eq!(char_style(&texts.style(Some(&run)), 1.0, to_text).fill, black_paint.clone(), "{to_text:?}"); + } + } + + /// A run's pattern stays where the document has it whatever the type's own text space: turned + /// and moved point type, and area type, whose text space is the document's. + #[test] + fn a_runs_pattern_stays_where_the_document_has_it() { + let to_doc = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 100.0]); + for frame in [ + FrameKind::Point([0.0, 1.0, -1.0, 0.0, 30.0, 40.0]), + FrameKind::Point([2.0, 0.0, 0.0, 2.0, -7.0, 3.0]), + FrameKind::Area(area_segments(), AreaFrame::default()), + ] { + let mut st = story("type", &[0.0], &Para::default()); + st.frames = vec![frame.clone()]; + st.runs[0].1.fill_pattern = Some(dots_at((10.0, 20.0))); + st.runs[0].1.stroke = Some(Color::BLACK); + st.runs[0].1.stroke_pattern = Some(dots_at((-3.0, 8.0))); + let node = st.node(NodeId(1), 0, (0.0, 0.0), to_doc).unwrap(); + let NodeKind::Text(t) = node.kind else { panic!("not type") }; + for (paint, at) in [(&t.runs[0].style.fill, Point::new(10.0, 20.0)), (&t.runs[0].style.stroke, Point::new(-3.0, 8.0))] { + let Paint::Pattern { pattern, xf } = paint else { panic!("{paint:?}") }; + assert_eq!(pattern, "Dots"); + let corner = t.xf * *xf * Point::ZERO; + assert!((corner - at).hypot() < 1e-9, "{frame:?}: {corner:?}"); + } + } + } + /// A right-to-left paragraph is laid out right to left even when it has no right-to-left /// letters (a dimension's label " 120.5 mm " reads "mm 120.5"); a left-to-right one, or one /// that doesn't say, goes by its letters. @@ -1651,6 +1781,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let direction = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).direction; assert_eq!(direction(b"/0 2"), Some(ParaDirection::RightToLeft), "from the document's defaults"); @@ -1698,6 +1829,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![read(doc.as_bytes()).unwrap()], + patterns: HashMap::new(), }; texts.frame(0) } @@ -1904,6 +2036,8 @@ mod tests { v_scale: 1.0, fill: Some(Color::BLACK), stroke: None, + fill_pattern: None, + stroke_pattern: None, stroke_width: 1.0, stroke_cap: LineCap::Butt, stroke_join: LineJoin::Miter, @@ -2352,6 +2486,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![read(doc.as_bytes()).unwrap()], + patterns: HashMap::new(), }; texts.frame(0).unwrap() } diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index c86cdb9516..978591b2d9 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -217,6 +217,7 @@ pub(super) fn build_from( placed_pages, envelopes_drawn, styles_read, + text_patterns, canvas_scale, xml_ids, bindings, @@ -374,7 +375,7 @@ pub(super) fn build_from( // The page's type that needs no text object of the layers: the layers' own legacy text, or // placed files that show it. let as_outlines = page_type_elsewhere(infos.is_empty(), infos.iter().all(|i| !i.shown), &mut shown, &mut hidden, &doc, &placed_elsewhere)?; - let texts = Texts::read(data); + let texts = Texts::read(data).map(|t| t.with_patterns(text_patterns)); // Each story is read once, however many text objects name it. let mut read: std::collections::BTreeMap>> = Default::default(); let stories: Vec>> = infos diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 8f64280367..23e8f0f70a 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -3366,6 +3366,71 @@ fn stroked_type_keeps_its_cap_join_and_miter_limit() { ); } +#[test] +fn type_painted_with_a_pattern_takes_it_from_the_art_style_its_paint_names() { + // GUARDS: type filled or stroked with a pattern swatch came in black: the text document keeps + // black as the paint's colour, and the pattern is in the art style that key 5 of the paint + // names, a style of one paint (`/SimpleStyle`) whose matrix is in art space. + use vectorcraft_color::{Color, Paint}; + use vectorcraft_geom::Point; + let dots = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 4 4\n%_0 0 0 1 k\n%_1 1 m\n%_3 1 L\n%_3 3 L\n%_1 3 L\n%_f\nE\n%AI3_EndPattern\n"; + let simple = |name: &str, paint: &str| { + format!( + "/KnownStyle :\n({name}) /Name ,\n/SimpleStyle :\n0 O\n{paint}\n0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n0 1 Xd\n/Paint ;\n /Def ;\n" + ) + }; + let pattern = |m: &str| format!("(Dots) 0 0 1 1 0 0 0 0 0 [{m}] p"); + let styles = [ + simple("Anon 1", &pattern("1 0 0 1 0 0")), + simple("Anon 2", &pattern("2 0 0 2 10 20")), + // Styles that give no pattern: one the file doesn't define, a matrix that doesn't place, no + // name, a colour after the pattern, a colour, a gradient. + simple("Anon 3", "(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p"), + simple("Anon 4", &pattern("0 0 0 0 0 0")), + simple("Anon 5", "0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p"), + simple("Anon 6", &format!("{}\n0 0 0 1 k", pattern("1 0 0 1 0 0"))), + simple("Anon 7", "0 0 0 1 k"), + simple("Anon 8", "0 Bb\n2 (Rainbow) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB"), + ] + .concat(); + let styles = format!("%AI9_BeginArtStyles\n{styles}%AI9_EndArtStyles\n"); + let doc = text_document(&[("abcdefghijklmnopq\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + let one = "<< /0 << /0 << /0 () /5 0 /6 << /0 0 /1 14.0 /53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >> >> >> /1 18 >>"; + assert!(doc.contains(one)); + let black = |style: &str| format!("<< /99 /CAITextPaint /0 << /0 0 /1 [ 1.0 0.0 ] >> /5 ({style}) >>"); + let run = |own: &str| format!("<< /0 << /0 << /0 () /5 0 /6 << /0 0 /1 14.0 {own} >> >> >> /1 2 >>"); + let mut runs = vec![run(&format!("/53 {}", black("Anon 1"))), run(&format!("/53 {} /57 true /54 {} /63 2.0", black("Anon 7"), black("Anon 2")))]; + runs.extend((3..=8).map(|n| run(&format!("/53 {}", black(&format!("Anon {n}")))))); + // A style the file doesn't have, over "q" and the story's end. + runs.push(run(&format!("/53 {}", black("Anon 9")))); + let doc = doc.replacen(one, &runs.join(" "), 1); + let art = native_with_text(&format!("{dots}{styles}{}{}", layer("Back", true, &square(10, 10)), layer("Spare", false, &text_object(0))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.len(), 1, "{:?}", r.warnings); + let t = texts[0]; + // Each character's fill and stroke, a pattern as where its tile's top left corner is. + let paint = |p: &Paint| match p { + Paint::Pattern { pattern, xf } => { + let at = t.xf * *xf * Point::ZERO; + format!("{pattern} at {:.3} {:.3}", at.x, at.y) + } + Paint::Solid { color, .. } if *color == Color::gray(1.0) => "black".into(), + Paint::None => "none".into(), + other => format!("{other:?}"), + }; + let chars: Vec<(char, String, String)> = + t.runs.iter().flat_map(|r| r.text.chars().map(|c| (c, paint(&r.style.fill), paint(&r.style.stroke))).collect::>()).collect(); + let want = |c: char, fill: &str, stroke: &str| (c, fill.to_string(), stroke.to_string()); + // The tile's top left (0, 4) in art space, on a page 100 high: (0, 96). Scaled twice and moved + // by (10, 20): (10, 28) in art space, (10, 72) in the document. + let mut expected = vec![want('a', "Dots at 0.000 96.000", "none"), want('b', "Dots at 0.000 96.000", "none")]; + expected.extend(['c', 'd'].map(|c| want(c, "black", "Dots at 10.000 72.000"))); + expected.extend("efghijklmnopq".chars().map(|c| want(c, "black", "none"))); + assert_eq!(chars, expected); + assert_eq!(r.document.pattern("Dots").map(|p| p.tile.height()), Some(4.0)); +} + #[test] fn orientation_and_tate_chu_yoko_values_it_cant_use_are_ignored() { for bad in ["(2)", "2.5", "1e300", "-2", "1", "true", "[ 2 ]"] { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index e2721d431b..09c34153f7 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (`.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern, from the art style its text document paint names, #6856; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -54,7 +54,10 @@ private editing data. VectorCraft: own paints with the paint before the mask's dictionary, as the writer leaves out paint that doesn't change. 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 and tab stops. Stroked type keeps its stroke's cap, join and miter limit. Area type of one + and the paragraph's hyphenation options and tab stops. Stroked type keeps its stroke's cap, join and miter limit. Type filled or + stroked with a pattern keeps its pattern, placed in the document as a path's pattern fill would be: a character's paint names + an art style of one paint, and the pattern is in that style (its colour in the text document is black). A pattern the file + doesn't define, or a style that isn't a pattern, keeps the colour as before. 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 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` @@ -340,6 +343,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | `.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern. In the text document a character's fill or stroke is a colour (key 0) and the name of an art style (key 5). In users' files (used locally, never committed) that style is one paint (`/SimpleStyle`): a colour, the one key 0 already has, or a pattern, `(name) … [matrix] p`, whose key 0 is black. Such type came in black, or stroked in black. It now takes the pattern, its matrix in art space as a path's pattern fill has it (the files' PDF parts tile such type with that matrix), placed in the type's text space so that it stays where the document has it. A pattern the file doesn't define or can't place, and a style of any other paint, keep the colour as before. A `.ai` saved again writes such type as its outlines, as it writes all type with pattern paint, with that warning. On 224 of our own files: envelopes of type that open live go from 154 in 45 files to 157 in 46, and no file loses one; no file changes whether it opens from its layers or its PDF part; renders at 40 ppi change in 10 of the 13 files with patterned type, 8 closer to the file's own page (one by under 0.01 points, in a file whose render differs from its page almost everywhere either way), 1 further by 0.03 points of its pixels (an envelope of type there now opens live in place of its drawn art), 1 not compared (its page renders at another size); notes change in 3 files: one loses its type note, one loses the note for type in an envelope and, now compared with its page, gains its type notes, and one that opens from its PDF part names a smaller difference. Tests `ate::tests::a_run_painted_by_a_patterns_art_style_takes_the_pattern`, `a_runs_pattern_stays_where_the_document_has_it`, `tests_native::type_painted_with_a_pattern_takes_it_from_the_art_style_its_paint_names`. Scores and estimates unchanged. (#6856) | | 2026-11-11 | minor | `.ai` and EPS import, and what the canvas, every export and `.ai` save draw: type's strokes in an envelope (its characters' and those on its own appearance) paint along the bent glyph outlines at their own weight, Distort Appearance on or off, as the drawn art of users' `.ai` files strokes them (used locally, never committed: there the drawn art keeps those strokes as stroked paths at the type's own weights, and its ring is as wide wherever the grid squeezes or stretches the type). With Distort Appearance, VectorCraft bent them with the art, as their filled outlines, so an envelope of stroked type in a squeezed or stretched grid didn't draw as its drawn art and kept it. The same holds in an envelope inside an envelope. Paths' strokes, geometry effects, opacity masks, linear gradients and pattern fills bend as before. On 224 of our own files: envelopes of type that open live go from 135 in 37 files to 154 in 45, and no file loses one; no file changes whether it opens from its layers or its PDF part; renders at 40 ppi change in 31 files, by at most 2.3% of their pixels, 19 closer to the file's own page and 12 further, 11 of those where an envelope of type now opens live in place of its drawn art (the page's own art) and one by 4 pixels. Tests `envelope_distort::type_strokes_paint_at_their_own_weight_in_a_squeezed_envelope`, `a_stroke_on_types_appearance_paints_at_its_own_weight_in_a_squeezed_envelope`, `a_paths_stroke_still_bends_in_a_squeezed_envelope`, `tests_native::an_envelope_of_stroked_type_whose_drawn_art_strokes_the_bent_outlines_at_the_weight_opens_live` (#6644) | | 2026-11-11 | minor | `.ai` and EPS import, and what SVG export, PDF export and `.ai` save draw: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as a `.ai`'s own page aligns them (see type-parity.md, same date). In 224 users' files (used locally, never committed), 63 render differently: 54 closer to the page, 7 further, 1 alike and 1 that couldn't be compared; among them the median share of pixels that differ from the page went from 4.4% to 1.0%. Of the 7: one now opens from its layers instead of its PDF part (they differ from the page by less than the import's 5% limit now; type note 3.8%); one has a centred line whose spacing inside it differs from the page's, which now centres on the page's line while half of it no longer lies on the page's glyphs (type note 1.4% to 2.4%); five moved by less than 0.05 of a percentage point, in type the page draws in other glyphs. The type note shows a smaller share in 6 files; 2 more envelopes of type open live (129 to 131, none fewer in any file). SVG import: `text-anchor` aligns a line's advance, which ends after its last character's letter spacing, so a centred or right-aligned letter-spaced line moves back by half or all of it and stays where a reader draws it; SVG export, which anchors lines so, reads back unchanged (test `import_text::anchored_letter_spaced_lines_stay_where_a_reader_draws_them`). Affinity import: centred or right-aligned tracked artistic text moves by half or all of its last character's tracking, as layout now aligns it (unverified against Affinity, whose files we don't have), and differently aligned paragraphs line up by the widths layout aligns them by (`affinity_features::artistic_text_aligns_its_lines_within_the_longest`). Scores and estimates unchanged. (#6610) | | 2026-11-11 | minor | `.ai` and EPS import: while an envelope's contents are edited, the editing data keeps its mesh (a mesh's, a top object's or a warp's grid) in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art, as if the mesh hadn't followed the envelope when it moved: the envelope drew its contents elsewhere and kept its drawn art. A mesh or top object saved in Edit Contents (or a warp of type tried on its saved grid) whose mesh draws the contents as large as the drawn art, within half a point over width and height together, but further from it than that, is now tried again, after every other way and with one more of the import's checks, with its mesh moved by as far as the drawn art's box lies from the box of what it draws; it opens live there where it then draws as the drawn art. In users' files, used locally and never committed, only envelopes saved in Edit Contents draw elsewhere than their drawn art, and nothing in their dictionaries says by how much they moved. Tests `ai::tests::an_envelope_in_edit_contents_whose_mesh_stayed_behind_opens_live_on_its_drawn_art`, `tests_native::a_warp_of_type_in_edit_contents_whose_grid_stayed_behind_opens_on_its_drawn_art` (#6570) | From 0a3abb8534bf7768b766f2356ce324a14bf1efec Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:00:15 -0800 Subject: [PATCH 14/92] Stroke: custom arrowheads cap subpaths, file size and preview size - A custom arrowhead path allows at most 4,096 subpaths; stroke.set refuses a longer one and reading drops it. - A symbol whose shapes cannot be united or clipped is listed in skipped instead of becoming a head that misses part of its art. - The arrowheads file is refused when its document unpacks to more than 64 MB. - Edit Arrowheads creates the starter file only when none exists and otherwise opens the file unchanged. - After a save to the arrowheads file, the heads are read back from the written file. - The plug-in argument check accepts path under startArrow.Custom and endArrow.Custom in stroke.set. - A custom head's menu preview is scaled to fit its box, and rows outside the visible part of the menu do not render previews. --- crates/doc/src/appearance/stroke_style.rs | 14 ++-- crates/doc/src/appearance/tests.rs | 7 ++ crates/engine/src/cmd/arrowheads.rs | 53 ++++++++++----- crates/engine/src/cmd/fileio/save.rs | 6 +- crates/engine/src/cmd/plugin_call.rs | 40 ++++++++++-- crates/engine/src/cmd/stroke.rs | 4 +- crates/engine/src/tests/arrowheads.rs | 65 +++++++++++++++++++ crates/format/src/lib.rs | 38 ++++++++++- crates/format/src/unpack.rs | 54 +++++++++++---- crates/ui-egui/src/panels/stroke.rs | 43 +++++++++--- .../ui-egui/src/panels/tests_strokedepth.rs | 37 +++++++++++ docs/mcp.md | 11 ++-- 12 files changed, 310 insertions(+), 62 deletions(-) diff --git a/crates/doc/src/appearance/stroke_style.rs b/crates/doc/src/appearance/stroke_style.rs index e029f8bc27..18b226c676 100644 --- a/crates/doc/src/appearance/stroke_style.rs +++ b/crates/doc/src/appearance/stroke_style.rs @@ -84,24 +84,26 @@ impl From for CustomArrowhead { } impl CustomArrowhead { - /// The most anchors a custom head's outline may have. + /// The most anchors a custom head's outline may have, and the most subpaths. pub const MAX_ANCHORS: usize = 4096; /// How far from the tip a custom head's outline may reach, in head units. pub const MAX_UNITS: f64 = 64.0; /// Head `name` with outline `path` (in head units). An outline with more than - /// [`Self::MAX_ANCHORS`] anchors, a point that is not finite, or a point farther than - /// [`Self::MAX_UNITS`] from the tip is dropped: the head then draws nothing. + /// [`Self::MAX_ANCHORS`] anchors or subpaths, a point that is not finite, or a point farther + /// than [`Self::MAX_UNITS`] from the tip is dropped: the head then draws nothing. pub fn new(name: String, path: PathData) -> Self { let path = if Self::fits(&path) { path } else { PathData::default() }; CustomArrowhead { name, path } } - /// Whether outline `path` is within [`Self::MAX_ANCHORS`] and [`Self::MAX_UNITS`], with finite - /// points only. + /// Whether outline `path` has at most [`Self::MAX_ANCHORS`] anchors and as many subpaths, with + /// finite points within [`Self::MAX_UNITS`] only. pub fn fits(path: &PathData) -> bool { let anchors = path.subpaths.iter().map(|s| s.anchors.len()).sum::(); let near = |p: vectorcraft_geom::Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= Self::MAX_UNITS && p.y.abs() <= Self::MAX_UNITS; - anchors <= Self::MAX_ANCHORS && path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| near(a.p) && near(a.h_in) && near(a.h_out)) + anchors <= Self::MAX_ANCHORS + && path.subpaths.len() <= Self::MAX_ANCHORS + && path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| near(a.p) && near(a.h_in) && near(a.h_out)) } fn bounds(&self) -> Option { self.path.control_bounds() diff --git a/crates/doc/src/appearance/tests.rs b/crates/doc/src/appearance/tests.rs index c814195596..22e5659051 100644 --- a/crates/doc/src/appearance/tests.rs +++ b/crates/doc/src/appearance/tests.rs @@ -387,6 +387,13 @@ fn custom_arrowheads_out_of_bounds_draw_nothing() { let anchors = vec![vectorcraft_geom::Anchor::corner(vectorcraft_geom::Point::new(1.0, 0.0)); CustomArrowhead::MAX_ANCHORS + 1]; let many = CustomArrowhead::new("Many".into(), vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::new(anchors, true))); assert!(many.path.subpaths.is_empty(), "too many anchors"); + // Empty subpaths add no anchors, and still count. + let mut hollow = c.path.clone(); + hollow.subpaths.extend(std::iter::repeat_n(vectorcraft_geom::SubPath::new(vec![], false), CustomArrowhead::MAX_ANCHORS)); + assert!(!CustomArrowhead::fits(&hollow), "too many subpaths"); + let json = serde_json::to_value(CustomArrowhead { name: "Hollow".into(), path: hollow }).unwrap(); + let hollow: CustomArrowhead = serde_json::from_value(json).unwrap(); + assert!(hollow.path.subpaths.is_empty(), "too many subpaths, read back"); } #[test] diff --git a/crates/engine/src/cmd/arrowheads.rs b/crates/engine/src/cmd/arrowheads.rs index 8e2eee865f..4dc58f6c8e 100644 --- a/crates/engine/src/cmd/arrowheads.rs +++ b/crates/engine/src/cmd/arrowheads.rs @@ -22,7 +22,8 @@ use super::{CommandSpec, Result, Session, always, bad, cmd}; /// The arrowheads file's name, in the folder of the preferences. pub const FILE_NAME: &str = "Arrowheads.vectorcraft"; -/// The largest arrowheads file read. +/// The largest arrowheads file read, and the most its document or its images may unpack to when +/// compressed. pub const MAX_FILE_BYTES: u64 = 64 << 20; /// The most heads read from the file; symbols past it are skipped. pub const MAX_HEADS: usize = 256; @@ -121,6 +122,7 @@ pub fn heads_of(doc: &Document) -> Read { /// Symbol `sym` of `d` as a head. fn head(d: &mut Document, brushes: &[vectorcraft_brush::Brush], sym: &Symbol) -> std::result::Result { const TOO_DETAILED: &str = "its art is too long or too detailed for a head"; + const UNITED: &str = "its shapes could not be combined into one"; if art_anchors(&sym.art) > MAX_ART_ANCHORS { return Err(TOO_DETAILED); } @@ -130,7 +132,10 @@ fn head(d: &mut Document, brushes: &[vectorcraft_brush::Brush], sym: &Symbol) -> if shapes.too_detailed { return Err(TOO_DETAILED); } - let area = shapes.united(); + if shapes.failed { + return Err(UNITED); + } + let area = shapes.united().ok_or(UNITED)?; let b = area.bounds().filter(|b| b.height() > 1e-9 && b.is_finite()).ok_or("its art paints nothing")?; let tip = b.center() + d.symbol_registration(&sym.name).offset_from_center(b.width(), b.height()); // Head units: x back from the tip, y across to the right of +x, 4 units to the art's height. @@ -172,6 +177,8 @@ struct Shapes { anchors: usize, /// More anchors were asked for than fit; the shapes past the limit were not made. too_detailed: bool, + /// A clip group's art could not be combined or clipped, so a shape it paints is missing. + failed: bool, } impl Shapes { @@ -186,9 +193,10 @@ impl Shapes { self.list.push(shape); } } - fn united(&self) -> PathData { + /// The shapes united, or `None` when one of them could not be (the union would miss it). + fn united(&self) -> Option { let refs: Vec<(&PathData, FillRule)> = self.list.iter().map(|(p, r)| (p, *r)).collect(); - vectorcraft_pathops::unite_all(&refs) + vectorcraft_pathops::try_unite_all(&refs).ok() } /// The shapes of `inside` clipped to `outline` (filled under `rule`), added as one. fn push_clipped(&mut self, inside: Shapes, outline: &PathData, rule: FillRule) { @@ -196,7 +204,14 @@ impl Shapes { self.too_detailed = true; return; } - let clipped = vectorcraft_pathops::boolean(&inside.united(), FillRule::NonZero, outline, rule, BoolOp::Intersect); + self.failed |= inside.failed; + let clip = |art: PathData| { + vectorcraft_pathops::try_boolean(&art, FillRule::NonZero, outline, rule, BoolOp::Intersect, vectorcraft_pathops::DEFAULT_PRECISION).ok() + }; + let Some(clipped) = inside.united().and_then(clip) else { + self.failed = true; + return; + }; if !clipped.subpaths.is_empty() { self.push((clipped, FillRule::NonZero)); } @@ -345,11 +360,11 @@ impl Session { } } - /// Document `doc` was saved to `path`: when that is the arrowheads file, its heads are the - /// ones listed now. - pub(crate) fn arrowheads_saved(&mut self, path: &str, doc: &Document) { + /// A document was saved to `path`: when that is the arrowheads file, its heads are read back + /// from what was written. + pub(crate) fn arrowheads_saved(&mut self, path: &str) { if self.arrowheads.path.as_deref().is_some_and(|a| same_file(a, path)) { - self.arrowheads.set(heads_of(doc)); + self.reload_arrowheads(); } } } @@ -371,7 +386,9 @@ fn read(path: &str) -> std::result::Result, String> { return Err(format!("{path}: larger than {} MB", MAX_FILE_BYTES >> 20)); } let bytes = crate::file_access::unconfined(|| super::fileio::read_file(path)).map_err(|e| e.to_string())?; - let parse = || super::fileio::native_file(&bytes).map(|(doc, ..)| doc).map_err(|e| e.to_string()); + // A compressed file's document, and its images, may unpack to no more than an uncompressed + // file may hold. + let parse = || vectorcraft_format::load_file_capped(&bytes, MAX_FILE_BYTES).map(|f| f.doc).map_err(|e| format!("{path}: {e}")); crate::guard::catch_panic(parse).map_err(|_| format!("{path}: could not be read"))?.map(Some) } @@ -400,13 +417,17 @@ fn reload(s: &mut Session, _: &Value) -> Result { fn edit(s: &mut Session, _: &Value) -> Result { const C: &str = "stroke.arrowheads.edit"; let path = s.arrowheads.path.clone().ok_or_else(|| bad(C, "no arrowheads file here"))?; - let created = !std::path::Path::new(&path).exists(); + if let Some(dir) = std::path::Path::new(&path).parent() { + std::fs::create_dir_all(dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?; + } + // Made only when no file is there, also when another run makes one at the same moment. + let bytes = vectorcraft_format::save_file(&starter()); + let created = match crate::file_access::unconfined(|| super::fileio::write_new_file(&path, &bytes)) { + Ok(()) => true, + Err(_) if std::path::Path::new(&path).exists() => false, + Err(e) => return Err(bad(C, e.to_string())), + }; if created { - if let Some(dir) = std::path::Path::new(&path).parent() { - std::fs::create_dir_all(dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?; - } - let bytes = vectorcraft_format::save_file(&starter()); - crate::file_access::unconfined(|| super::fileio::write_file(&path, &bytes)).map_err(|e| bad(C, e.to_string()))?; s.reload_arrowheads(); } // Already open: that document comes to the front. diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 6e814569a0..713feb82ae 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -1006,7 +1006,7 @@ impl SaveJob { // The other open documents that place this one show the new version. let written = self.plan.path.clone().filter(|_| matches!(self.plan.format.id, "vectorcraft" | "template")); // A save of the arrowheads file lists its heads in the arrowhead menus. - let native = (self.plan.format.id == "vectorcraft").then(|| self.snapshot.clone()); + let native = self.plan.format.id == "vectorcraft"; if let Some(st) = s.document_mut(uid) { self.finish(st); } @@ -1014,8 +1014,8 @@ impl SaveJob { crate::cmd::recovery::forget(s, uid); } if let Some(path) = written { - if let Some(doc) = native { - s.arrowheads_saved(&path, &doc); + if native { + s.arrowheads_saved(&path); } crate::cmd::links::refresh_placed(s, uid, &path); } diff --git a/crates/engine/src/cmd/plugin_call.rs b/crates/engine/src/cmd/plugin_call.rs index b151adcea2..b94a791240 100644 --- a/crates/engine/src/cmd/plugin_call.rs +++ b/crates/engine/src/cmd/plugin_call.rs @@ -121,6 +121,10 @@ pub(crate) const NOT_FILES: &[(&str, &str)] = &[ ("addon.cleanup.rejoin", "paths"), ]; +/// Commands and the keys from the top of their params down to a key in [`FILE_KEYS`] that, there, +/// names part of the document, not a file: a custom arrowhead's outline in `stroke.set`. +const NESTED_NOT_FILES: &[(&str, &[&str])] = &[("stroke.set", &["startArrow", "Custom", "path"]), ("stroke.set", &["endArrow", "Custom", "path"])]; + /// Is command id `id` one a plug-in may never call, from its id alone ([`DENIED_PREFIXES`], /// [`FILE_WORDS`])? fn denied_id(id: &str) -> bool { @@ -184,20 +188,32 @@ pub(crate) fn names_key(text: &str, key: &str) -> bool { /// Does `args`, the arguments of a call of command `id`, carry a key in [`FILE_KEYS`] at any depth /// (in objects and in arrays), such as an effect's `textureImage.path`? Only a key [`NOT_FILES`] -/// names for `id`, at the top level, isn't counted. The walk keeps its own stack, so deep -/// arguments can't overflow the thread's. +/// names for `id`, at the top level, and one at the end of a [`NESTED_NOT_FILES`] key list for +/// `id`, aren't counted. The walk keeps its own stack, so deep arguments can't overflow the +/// thread's. fn names_file(id: &str, args: &Value) -> bool { - let mut stack = vec![(args, true)]; - while let Some((v, top)) = stack.pop() { + // Each value, whether it is the params themselves, and the keys of a NESTED_NOT_FILES list + // still to follow below it. + let mut stack: Vec<(&Value, bool, Option<&[&str]>)> = vec![(args, true, None)]; + while let Some((v, top, trail)) = stack.pop() { match v { - Value::Array(a) => stack.extend(a.iter().map(|x| (x, false))), + Value::Array(a) => stack.extend(a.iter().map(|x| (x, false, None))), Value::Object(o) => { for (k, x) in o { let k = k.as_str(); - if FILE_KEYS.contains(&k) && !(top && NOT_FILES.contains(&(id, k))) { + let rest = if top { + NESTED_NOT_FILES + .iter() + .find(|(c, keys)| *c == id && keys.first() == Some(&k)) + .map(|(_, keys)| keys.get(1..).unwrap_or_default()) + } else { + trail.filter(|keys| keys.first() == Some(&k)).map(|keys| keys.get(1..).unwrap_or_default()) + }; + let document = (top && NOT_FILES.contains(&(id, k))) || rest.is_some_and(<[&str]>::is_empty); + if FILE_KEYS.contains(&k) && !document { return true; } - stack.push((x, false)); + stack.push((x, false, rest.filter(|keys| !keys.is_empty()))); } } _ => {} @@ -536,6 +552,16 @@ mod tests { assert!(!names_file("object.setLiveShape", &json!({"path": [[0, 0], [1, 1]]}))); assert!(names_file("object.setLiveShape", &json!({"options": {"path": "/tmp/x"}}))); assert!(names_file("object.move", &json!({"path": [[0, 0]]}))); + // A custom arrowhead's outline in stroke.set, only where stroke.set takes one. + let outline = json!({"subpaths": []}); + assert!(!names_file("stroke.set", &json!({"endArrow": {"Custom": {"name": "A", "path": outline}}}))); + assert!(!names_file("stroke.set", &json!({"startArrow": {"Custom": {"name": "A", "path": outline}}, "endArrow": "Triangle"}))); + assert!(names_file("stroke.set", &json!({"endArrow": {"Custom": {"name": "A", "path": outline, "file": "/tmp/x"}}}))); + assert!(names_file("stroke.set", &json!({"endArrow": {"Custom": {"path": {"path": "/tmp/x"}}}}))); + assert!(names_file("stroke.set", &json!({"endArrow": {"path": "/tmp/x"}}))); + assert!(names_file("stroke.set", &json!({"options": {"endArrow": {"Custom": {"path": "/tmp/x"}}}}))); + assert!(names_file("stroke.set", &json!({"endArrow": [{"Custom": {"path": "/tmp/x"}}]}))); + assert!(names_file("object.move", &json!({"endArrow": {"Custom": {"path": "/tmp/x"}}}))); // Deep nesting is walked without recursion. let mut deep = json!({"file": "/tmp/x"}); for _ in 0..10_000 { diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 40b2a7aa86..aba92ea200 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -22,7 +22,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", + "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", has_doc, stroke_set ), @@ -245,7 +245,7 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { return Err(bad( C, format!( - "{k}: a custom arrowhead's path needs an area to fill and at most {most} anchors, each within {far} head units of its tip" + "{k}: a custom arrowhead's path needs an area to fill and at most {most} anchors and {most} subpaths, each anchor within {far} head units of its tip" ), )); } diff --git a/crates/engine/src/tests/arrowheads.rs b/crates/engine/src/tests/arrowheads.rs index 2fdbbebca9..6b31fbcfda 100644 --- a/crates/engine/src/tests/arrowheads.rs +++ b/crates/engine/src/tests/arrowheads.rs @@ -283,3 +283,68 @@ fn an_unreadable_arrowheads_file_lists_no_heads_and_says_why() { assert!(bare.execute("stroke.arrowheads.edit", &json!({})).is_err()); assert_eq!(bare.execute("stroke.arrowheads.list", &json!({})).unwrap()["file"], serde_json::Value::Null); } + +#[test] +fn the_heads_listed_after_a_save_are_read_from_the_file_and_editing_keeps_it() { + let dir = folder("saved"); + let file = dir.join(arrowheads::FILE_NAME).to_string_lossy().to_string(); + let (mut s, _) = session_with_line(); + s.attach_arrowheads_file(file.clone()); + assert_eq!(s.execute("stroke.arrowheads.edit", &json!({})).unwrap()["created"], true); + // A speck that saving rounds to a point is a head only before the save. + let st = s.doc_mut().unwrap(); + add_symbol(Arc::make_mut(&mut st.doc), "Speck", polygon(&[(0.0, 0.0), (0.3, 0.1), (0.0, 0.2)], filled())); + assert!(heads_of(&s.doc().unwrap().doc).heads.iter().any(|h| h.name == "Speck"), "a head before rounding"); + s.execute("document.save", &json!({"precision": 0})).unwrap(); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + let names: Vec<&str> = list["arrowheads"].as_array().unwrap().iter().filter_map(|h| h["name"].as_str()).collect(); + assert_eq!(names, ["Example Arrowhead"]); + assert_eq!(list["skipped"][0]["name"], "Speck", "{list}"); + // Editing again leaves the file as it is. + let saved = std::fs::read(&file).unwrap(); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert_eq!(s.execute("stroke.arrowheads.edit", &json!({})).unwrap()["created"], false); + assert_eq!(std::fs::read(&file).unwrap(), saved, "the file is left as it was"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn an_arrowheads_file_that_unpacks_past_the_size_limit_is_not_read() { + let dir = folder("unpacked"); + let file = dir.join(arrowheads::FILE_NAME); + let mut d = arrowheads::starter(); + d.title = "a".repeat(arrowheads::MAX_FILE_BYTES as usize); + let bytes = vectorcraft_format::save_with(&d, &vectorcraft_format::SaveOptions { compress: true, ..Default::default() }).unwrap(); + assert!((bytes.len() as u64) < arrowheads::MAX_FILE_BYTES / 16, "compressed small: {}", bytes.len()); + std::fs::write(&file, &bytes).unwrap(); + let mut s = Session::new(); + s.attach_arrowheads_file(file.to_string_lossy().to_string()); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + assert!(list["arrowheads"].as_array().unwrap().is_empty(), "{}", list["arrowheads"]); + assert!(list["error"].is_string(), "{}", list["error"]); + // The same head in a file of an ordinary size is read. + std::fs::write(&file, vectorcraft_format::save_file(&arrowheads::starter())).unwrap(); + assert_eq!(s.execute("stroke.arrowheads.reload", &json!({})).unwrap()["count"], 1); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_symbol_whose_shapes_cannot_be_combined_is_skipped() { + let wedge = || Arc::new(polygon(&[(0.0, -10.0), (20.0, 0.0), (0.0, 10.0)], filled())); + // Too far out for the shape operations to measure: the union would leave it out. + let far = || Arc::new(polygon(&[(0.0, 0.0), (1e200, 0.0), (0.0, 1e200)], filled())); + let mut d = Document::new(100.0, 100.0); + add_symbol(&mut d, "Wedge", (*wedge()).clone()); + add_symbol(&mut d, "Broken", Node::group(NodeId(0), vec![wedge(), far()])); + // The same as clipped art. + let mut mask = polygon(&[(0.0, -10.0), (20.0, -10.0), (20.0, 10.0), (0.0, 10.0)], Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut mask.kind { + *clipping = true; + } + add_symbol(&mut d, "Broken Clip", Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(mask), wedge(), far()], clip: true })); + let read = heads_of(&d); + let names: Vec<&str> = read.heads.iter().map(|h| h.name.as_str()).collect(); + assert_eq!(names, ["Wedge"]); + let skipped: Vec<&str> = read.skipped.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!(skipped, ["Broken", "Broken Clip"], "{:?}", read.skipped); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index cd64fcbb50..f3efb3784b 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -504,11 +504,23 @@ pub fn load_file(bytes: &[u8]) -> Result { load_file_within(bytes, vectorcraft_doc::node::MAX_READ_NODES, vectorcraft_doc::node::MAX_BUFFERED_VALUES) } +/// [`load_file`] with a compressed file refused when its JSON, or the data after it that the +/// document uses (images, profiles), unpacks to more than `max` bytes (at most [`MAX_JSON`] and +/// [`MAX_BLOBS`]), or, when it lacks the marker after its JSON, when all of it does. +pub fn load_file_capped(bytes: &[u8], max: u64) -> Result { + let file = unpack::Unpacked::within_capped(bytes, max.min(MAX_JSON), max.min(MAX_UNPACKED), max.min(MAX_BLOBS))?; + load_unpacked(file, vectorcraft_doc::node::MAX_READ_NODES, vectorcraft_doc::node::MAX_BUFFERED_VALUES) +} + /// [`load_file`] reading at most `max_nodes` objects and buffering at most `max_values` values /// before objects' types, so a test can exercise the budgets without documents that large /// ([`vectorcraft_doc::node::MAX_READ_NODES`], [`vectorcraft_doc::node::MAX_BUFFERED_VALUES`]). fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result { - let file = unpack::Unpacked::new(bytes)?; + load_unpacked(unpack::Unpacked::new(bytes)?, max_nodes, max_values) +} + +/// The document of `file`, within the budgets of [`load_file_within`]. +fn load_unpacked(file: unpack::Unpacked<'_>, max_nodes: usize, max_values: usize) -> Result { // The JSON reader stops at 128 levels (a document's objects use three each), the paths read // at `MAX_READ_ANCHORS` anchors together, the document at `MAX_READ_NODES` objects, and the // fields read before an object's type at `MAX_BUFFERED_VALUES` values. The preview and the @@ -1186,6 +1198,30 @@ mod tests { assert_eq!(back.node(id).unwrap().path_data(), d.node(id).unwrap().path_data()); } + /// A capped read refuses a compressed file whose images unpack past the cap, before reading + /// them, and reads the same file under a cap it fits. + #[test] + fn a_capped_load_caps_the_data_after_the_json() { + use std::io::Write as _; + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + d.images.insert("k".into(), ImageBlob::new("image/png", vec![7; 1 << 16])); + let img = d.alloc_id(); + let im = ImageObject { key: "k".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + d.insert(Some(l), 0, Node::new(img, NodeKind::Image(Box::new(im)))).unwrap(); + // The file gzipped whole, image and all, so the image unpacks from a few bytes. + let raw = save_with(&d, &SaveOptions::default()).unwrap(); + let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best()); + gz.write_all(&raw).unwrap(); + let bytes = gz.finish().unwrap(); + assert!(is_compressed(&bytes) && bytes.len() < 1 << 12, "{}", bytes.len()); + let json = unpack::Unpacked::new(&bytes).unwrap().json().len(); + assert!(json < 1 << 15, "{json}"); + assert!(matches!(load_file_capped(&bytes, (1 << 16) - 1), Err(FormatError::TooBig))); + assert_eq!(load_file_capped(&bytes, 3 << 15).unwrap().doc.images["k"].bytes.len(), 1 << 16); + assert_eq!(load_file(&bytes).unwrap().doc.images["k"].bytes.len(), 1 << 16); + } + /// #3587: objects are read within a budget. A compressed file of many small objects stays /// small on disk while they take far more memory once read, so past the budget it is refused /// with a message naming it, before the rest of the document is read. diff --git a/crates/format/src/unpack.rs b/crates/format/src/unpack.rs index 1573fa0bc7..fed726bfab 100644 --- a/crates/format/src/unpack.rs +++ b/crates/format/src/unpack.rs @@ -28,6 +28,10 @@ pub(crate) type Entry = Option>>; pub(crate) struct Unpacked<'a> { json: Json<'a>, tail: Tail<'a>, + /// Most bytes the entries after the JSON may have together ([`MAX_BLOBS`] unless capped). + max_blobs: u64, + /// Most bytes unpacked to find them in a streamed file ([`MAX_WORK`] unless capped). + max_work: u64, } enum Json<'a> { @@ -68,9 +72,23 @@ impl<'a> Unpacked<'a> { /// a document several times its size, and the cap is what keeps that off the address space /// (#3587). fn within(bytes: &'a [u8], max_json: u64) -> Result { + Self::within_capped(bytes, max_json, MAX_UNPACKED, MAX_BLOBS) + } + + /// [`Self::within`], with a compressed file that lacks the marker after its JSON (unpacked + /// whole) capped at `max_whole` bytes instead of [`MAX_UNPACKED`], the entries after the JSON + /// at `max_blobs` bytes together instead of [`MAX_BLOBS`], and the bytes unpacked to find them + /// at `max_json` and `max_blobs` together instead of [`MAX_WORK`]. + pub(crate) fn within_capped(bytes: &'a [u8], max_json: u64, max_whole: u64, max_blobs: u64) -> Result { + let (json, tail) = Self::open(bytes, max_json, max_whole)?; + Ok(Self { json, tail, max_blobs, max_work: max_json.saturating_add(max_blobs).min(MAX_WORK) }) + } + + /// The JSON of `bytes` and where the data after it is, within `max_json` and `max_whole`. + fn open(bytes: &'a [u8], max_json: u64, max_whole: u64) -> Result<(Json<'a>, Tail<'a>), FormatError> { if !is_compressed(bytes) { let (json, tail) = split_tail(bytes); - return Ok(Self { json: Json::Borrowed(json), tail: Tail::Slice(tail) }); + return Ok((Json::Borrowed(json), Tail::Slice(tail))); } let mut dec = GzDecoder::new(bytes); let mut buf = Vec::new(); @@ -93,7 +111,7 @@ impl<'a> Unpacked<'a> { if buf.len() as u64 > max_json { return Err(FormatError::TooBig); } - return Ok(Self { json: Json::Owned(buf), tail: Tail::None }); + return Ok((Json::Owned(buf), Tail::None)); } // The marker where the writer puts it, right after the JSON's last byte: what is unpacked // so far reaches past it. @@ -104,17 +122,17 @@ impl<'a> Unpacked<'a> { let read = buf.split_off(end + BLOB_MAGIC.len()); buf.truncate(end); let at = (end + BLOB_MAGIC.len()) as u64; - return Ok(Self { json: Json::Owned(buf), tail: Tail::Stream { file: bytes, at, read, rest: Box::new(dec) } }); + return Ok((Json::Owned(buf), Tail::Stream { file: bytes, at, read, rest: Box::new(dec) })); } // Anything else (hand-made or damaged files) is unpacked whole and split as an // uncompressed file is. - dec.take(MAX_UNPACKED + 1 - (buf.len() as u64).min(MAX_UNPACKED)).read_to_end(&mut buf).map_err(damaged)?; - if buf.len() as u64 > MAX_UNPACKED { + dec.take(max_whole + 1 - (buf.len() as u64).min(max_whole)).read_to_end(&mut buf).map_err(damaged)?; + if buf.len() as u64 > max_whole { return Err(FormatError::TooBig); } let (json, tail) = split_tail(&buf); let (json, tail) = (json.len(), buf.len() - tail.len()); - Ok(Self { json: Json::Whole { buf, json }, tail: Tail::Whole(tail) }) + Ok((Json::Whole { buf, json }, Tail::Whole(tail))) } /// The JSON text. @@ -133,13 +151,16 @@ impl<'a> Unpacked<'a> { /// of an uncompressed file larger together than both [`MAX_BLOBS`] and the data, or the /// distinct entries found larger together than the data they are in (overlapping entries /// would copy the same bytes once each, #3574). Entries are read in the order of their - /// offsets, each straight into its own buffer. + /// offsets, each straight into its own buffer. A capped file ([`Self::within_capped`]) takes + /// its own totals in place of [`MAX_BLOBS`] and [`MAX_WORK`]. pub(crate) fn entries(self, wanted: &[[u64; 2]]) -> Result, FormatError> { - self.entries_within(wanted, MAX_WORK) + let work = self.max_work; + self.entries_within(wanted, work) } /// [`Self::entries`], unpacking at most `work` bytes of a streamed file to find them. fn entries_within(self, wanted: &[[u64; 2]], work: u64) -> Result, FormatError> { + let max_blobs = self.max_blobs; let mut out: Vec = vec![None; wanted.len()]; // Data in memory is read through the crate's `Entries`: entries naming the same bytes // share them, and the bytes copied stay within the data. @@ -155,11 +176,11 @@ impl<'a> Unpacked<'a> { }; match self.tail { Tail::Slice(tail) => { - within(wanted, u64::MAX, MAX_BLOBS.max(tail.len() as u64))?; + within(wanted, u64::MAX, max_blobs.max(tail.len() as u64))?; in_memory(tail, &mut out)?; } Tail::Whole(at) => { - within(wanted, MAX_BLOB, MAX_BLOBS)?; + within(wanted, MAX_BLOB, max_blobs)?; let buf = match &self.json { Json::Whole { buf, .. } => buf.as_slice(), _ => &[], @@ -168,7 +189,7 @@ impl<'a> Unpacked<'a> { } Tail::None => in_memory(&[], &mut out)?, Tail::Stream { file, at, read, rest } => { - within(wanted, MAX_BLOB, MAX_BLOBS)?; + within(wanted, MAX_BLOB, max_blobs)?; drop(self.json); // The entries read so far, by `[offset, length]`, and the bytes copied for them. let mut done: HashMap<[u64; 2], Entry> = HashMap::new(); @@ -194,8 +215,8 @@ impl<'a> Unpacked<'a> { done.insert(w, slot.clone()); } // To the end, so the stream's checksum is verified as before. - s.left = MAX_BLOBS; - s.skip(MAX_BLOBS)?; + s.left = max_blobs; + s.skip(max_blobs)?; } } Ok(out) @@ -317,6 +338,13 @@ mod tests { // An offset past the data is still just missing, and the default budget reads all of it. assert_eq!(read(&[[1 << 40, 1]], 1 << 20).unwrap(), vec![None]); assert_eq!(Unpacked::new(&file).unwrap().entries(&[[0, 4096]]).unwrap(), vec![Some(Arc::new(tail.clone()))]); + // A capped file finds its entries within its own caps: a small entry far into the data, + // or entries larger than the cap together, are refused. + let capped = || Unpacked::within_capped(&file, 64, MAX_UNPACKED, 16).unwrap(); + assert_eq!(capped().entries(&[[0, 16]]).unwrap(), vec![Some(Arc::new(tail[..16].to_vec()))]); + assert!(matches!(capped().entries(&[[4000, 1]]), Err(FormatError::TooBig))); + assert!(matches!(capped().entries(&[[0, 17]]), Err(FormatError::TooBig))); + assert_eq!(Unpacked::new(&file).unwrap().entries(&[[4000, 1]]).unwrap(), vec![Some(Arc::new(vec![tail[4000]]))]); } /// #3587: a compressed file's JSON is capped wherever the cap runs, so a small gzip that diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index 7be5b65e7c..894b8488f8 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -367,22 +367,44 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } /// The document an arrowhead preview renders: a `w`×`h` pt line with head `a` at its left -/// (`start`) or right end, its tip on the end point, drawn white (the preview is tinted). -pub(super) fn arrow_doc(a: Option, start: bool, w: f64, h: f64) -> Option { - let mut doc = Document::new(w, h); - let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::WHITE), 1.5); - let st = ap.stroke_mut()?; +/// (`start`) or right end, its tip on the end point, drawn white (the preview is tinted). A custom +/// head is made smaller than the built-in ones when it would not fit (below the smallest head +/// weight, its outline is scaled down), and the line's end moves in by as much as the head reaches +/// past its tip. +pub(super) fn arrow_doc(mut a: Option, start: bool, w: f64, h: f64) -> Option { + const WIDTH: f64 = 1.5; + // The smallest weight `StrokeLayer::arrow_weight` gives a head. + const LEAST: f64 = 0.25; // Heads of weight 2.5 pt: 10 pt long and wide. - st.arrow_scale = (500.0 / 3.0, 500.0 / 3.0); + let mut scale: f64 = 500.0 / 3.0; + let mut inset = 0.0; + if let Some(Arrowhead::Custom(c)) = &a + && let Some(b) = c.path.control_bounds() + { + let (long, half) = (b.x1.max(0.0) - b.x0.min(0.0), b.y0.abs().max(b.y1.abs())); + let fit = |room: f64, units: f64| if units > 1e-9 { room.max(0.0) / units } else { f64::INFINITY }; + let weight = fit(w - 6.0, long).min(fit(h / 2.0 - 1.0, half)).min(2.5); + inset = -b.x0.min(0.0) * weight; + if weight < LEAST { + let path = c.path.transformed(vectorcraft_geom::Affine::scale(weight / LEAST)); + a = Some(Arrowhead::Custom(std::sync::Arc::new(vectorcraft_doc::CustomArrowhead::new(c.name.clone(), path)))); + } + scale = scale.min(weight.max(LEAST) / WIDTH * 100.0); + } + let mut doc = Document::new(w, h); + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::WHITE), WIDTH); + let st = ap.stroke_mut()?; + st.arrow_scale = (scale, scale); st.arrow_align = ArrowAlign::Tip; if start { st.start_arrow = a } else { st.end_arrow = a } + let (x0, x1) = if start { (3.0 + inset, w - 3.0) } else { (3.0, w - 3.0 - inset) }; let mut bp = vectorcraft_geom::BezPath::new(); - bp.move_to((3.0, h / 2.0)); - bp.line_to((w - 3.0, h / 2.0)); + bp.move_to((x0, h / 2.0)); + bp.line_to((x1, h / 2.0)); let (id, l) = (doc.alloc_id(), doc.layers[0].id); doc.insert(Some(l), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), ap)).ok()?; Some(doc) @@ -419,7 +441,10 @@ fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option<&Arrowhead>, custom: &[Arro } else if rr.hovered() { ui.painter().rect_filled(row, 0.0, t.hover); } - paint_arrow(ui, Rect::from_min_size(row.min + vec2(4.0, 0.0), vec2(56.0, 22.0)), a, start, t.text_strong); + // Only the rows in view render their previews. + if ui.is_rect_visible(row) { + paint_arrow(ui, Rect::from_min_size(row.min + vec2(4.0, 0.0), vec2(56.0, 22.0)), a, start, t.text_strong); + } ui.painter().text( row.left_center() + vec2(66.0, 0.0), egui::Align2::LEFT_CENTER, diff --git a/crates/ui-egui/src/panels/tests_strokedepth.rs b/crates/ui-egui/src/panels/tests_strokedepth.rs index c085ccf385..1d80cc1c66 100644 --- a/crates/ui-egui/src/panels/tests_strokedepth.rs +++ b/crates/ui-egui/src/panels/tests_strokedepth.rs @@ -111,6 +111,43 @@ fn every_arrowhead_preview_draws_its_head() { assert!(ink(Some(custom), true) > plain.1 + 20); } +#[test] +fn a_long_custom_arrowhead_preview_fits_in_its_box() { + use vectorcraft_doc::Arrowhead; + // 40 units long and 4 wide, reaching 4 units past its tip. + let pts = [(-4.0, -2.0), (36.0, -2.0), (36.0, 2.0), (-4.0, 2.0)].map(|(x, y)| vectorcraft_geom::Point::new(x, y)); + let bar = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true)); + // 120 units across: smaller than the smallest head weight can draw it. + let pts = [(0.0, -60.0), (4.0, -60.0), (4.0, 60.0), (0.0, 60.0)].map(|(x, y)| vectorcraft_geom::Point::new(x, y)); + let wall = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true)); + let heads = [bar, wall].map(|p| Arrowhead::Custom(std::sync::Arc::new(vectorcraft_doc::CustomArrowhead::new("Bar".into(), p)))); + for (head, start) in heads.iter().flat_map(|h| [(h, false), (h, true)]) { + let d = stroke::arrow_doc(Some(head.clone()), start, 56.0, 22.0).unwrap(); + // Rendered well beyond the 56 × 22 pt box, at 1 pixel a point: all the ink is inside it. + let img = vectorcraft_render::Renderer::new().render_region(&d, vectorcraft_geom::Rect::new(-100.0, -50.0, 156.0, 72.0), 1.0, false); + let ink: Vec<(u32, u32)> = (0..256).flat_map(|x| (0..122).map(move |y| (x, y))).filter(|&(x, y)| img.pixel(x, y)[3] > 64).collect(); + assert!(ink.len() > 40, "start {start}: the line and head draw"); + let outside: Vec<&(u32, u32)> = ink.iter().filter(|&&(x, y)| !(100..156).contains(&x) || !(50..72).contains(&y)).collect(); + assert!(outside.is_empty(), "start {start}: {} pixels outside the box, e.g. {:?}", outside.len(), outside.first()); + } +} + +#[test] +fn the_arrowhead_menu_renders_only_the_previews_in_view() { + let ctx = egui::Context::default(); + let mut app = app_with_line(); + let t = frame_events(&ctx, &mut app, vec![], stroke::show); + let label = text_rect(&t, "Arrowheads:"); + let sp = ctx.global_style().spacing.item_spacing.x; + click(&ctx, &mut app, egui::pos2(label.right() + 2.0 * sp + 64.0 + 32.0, label.center().y), stroke::show); + frame_events(&ctx, &mut app, vec![], stroke::show); + frame_events(&ctx, &mut app, vec![], stroke::show); + let previews = ctx.tex_manager().read().allocated().filter(|(_, m)| m.name.contains(":arrow:") && m.name.ends_with(":false")).count(); + // The button's and the rows' in the 360 pt list, not one for each of the menu's heads. + let rows = vectorcraft_doc::Arrowhead::ALL.len() + 1; + assert!(previews > 5 && previews < rows / 2 + 5, "{previews} previews for {rows} rows"); +} + #[test] fn arrowhead_dropdown_offers_the_new_heads() { let ctx = egui::Context::default(); diff --git a/docs/mcp.md b/docs/mcp.md index aa6f7bcd69..a306e8061e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3960,17 +3960,18 @@ right, the symbol's registration point is its tip, and the art's height is drawn head weights). The visible fills and the outlined visible strokes of its paths, compound paths and groups, and its gradient meshes, make the head, with geometry effects applied and clip groups clipped, and the line stops halfway along its length. A head is one solid shape in the stroke's paint: art at 0 % opacity is left out, and other opacities, blend modes and opacity -masks do not change the shape. The file is read when the app starts and again each time VectorCraft saves it; -`stroke.arrowheads.reload` reads it again. A file larger than 64 MB is not read, at most 256 symbols are read, and a -symbol that paints nothing, reaches more than 64 head weights from its tip, has more than 65,536 anchors in its art or -in the shapes it paints, or has more than 4,096 anchors once united is listed in `skipped`. `stroke.arrowheads.list` returns each head's `name`, `reach` and `halfWidth`, the `file` +masks do not change the shape. The file is read when the app starts and again from disk each time VectorCraft saves it; +`stroke.arrowheads.reload` reads it again. A file larger than 64 MB, or whose document or images unpack to more than 64 MB, is +not read, at most 256 symbols are read, and a symbol that paints nothing, reaches more than 64 head weights from its +tip, has more than 65,536 anchors in its art or in the shapes it paints, has shapes that cannot be combined into one, +or has more than 4,096 anchors once united is listed in `skipped`. `stroke.arrowheads.list` returns each head's `name`, `reach` and `halfWidth`, the `file` path, the `skipped` symbols and any read `error`. `stroke.set {endArrow: {"custom": name}}` puts a custom head on a stroke. The stroke keeps a copy of the head's art, so the document draws it, saves it and exports it to SVG and PDF the same way without the arrowheads file. `document.inspect` reports such a head as `{"Custom": {name, path}}`, which `stroke.set` takes back unchanged. It refuses a `path` that fills no area (non-zero rule; parts thinner than a millionth of a head unit count as none) or -has more than 4,096 anchors or a point more than 64 head units from the tip. The help page +has more than 4,096 anchors, more than 4,096 subpaths or a point more than 64 head units from the tip. The help page [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html) describes defining custom arrowheads in a separate arrowheads file. From 61298535550e526b820b56bee43446f416deab4c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:07:12 -0800 Subject: [PATCH 15/92] Effects: a Transform effect transforms the raster effects listed before it Effects apply in the order they are listed, so a Drop Shadow, glow, blur, Feather or pixel filter listed before a Transform effect paints art that the Transform effect then transforms. VectorCraft drew such an effect at its own distances around the transformed art: a square scaled to 200% kept a 10 pt shadow offset. Now the effect follows the Transform effects after it, with Scale Strokes & Effects on or off: its sizes scale by the mean scale, and a Drop Shadow's offset turns, reflects and scales with the art. Effects listed after the Transform effect keep their values, and Transform Objects off changes nothing. - raster_effects reads each raster effect through the Transform effects after it, so the canvas, export and the redraw region use the followed values. - Where the geometry effects are applied and removed (Expand Appearance, baking for export, evaluated groups), the raster effects that remain carry the same transformation, hidden ones included. - A Transform effect that makes copies or moves at random leaves the raster effects before it as they are. Rasterize and SVG filters keep their options. Docs: the Transform effect's params text, docs/target-app-parity.md. --- crates/effects/src/bake.rs | 13 +- crates/effects/src/catalog.rs | 2 +- crates/effects/src/group.rs | 5 +- crates/effects/src/raster.rs | 94 +++++++++++- crates/effects/src/reshape.rs | 2 +- crates/effects/src/tests.rs | 1 + crates/effects/src/tests/rasterfollow.rs | 173 +++++++++++++++++++++++ crates/render/src/fx/tests.rs | 30 ++++ docs/target-app-parity.md | 1 + 9 files changed, 307 insertions(+), 14 deletions(-) create mode 100644 crates/effects/src/tests/rasterfollow.rs diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 795fc69bdf..24f3d68af6 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -29,7 +29,7 @@ use crate::group::{has_own_paint, paints}; use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; use crate::{ GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry, - has_item_geometry, has_pathfinder, is_geometry, is_pathfinder, merged_params, needs_outline, outline_text, reshape, + has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, outline_text, reshape, }; fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { @@ -41,8 +41,8 @@ fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { fn clear_item_effects(item: &mut AppearanceItem) { match item { - AppearanceItem::Fill(f) => f.effects.retain(|e| !is_geometry(&e.id)), - AppearanceItem::Stroke(s) => s.effects.retain(|e| !is_geometry(&e.id)), + AppearanceItem::Fill(f) => f.effects = crate::raster::without_geometry(&f.effects), + AppearanceItem::Stroke(s) => s.effects = crate::raster::without_geometry(&s.effects), } } @@ -337,7 +337,7 @@ fn moved_type_group(n: &Node) -> Option { g.knockout = Knockout::Off; g.knockout_shape = n.knockout_shape; g.extras_mut().mask = n.extras().mask.clone(); - g.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); + g.appearance.effects = crate::raster::without_geometry(&n.appearance.effects); Some(g) } @@ -397,7 +397,8 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option { return None; } reshape_leaves(&mut m, &fx, &n.appearance.effects, Some(d)); - m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !is_geometry(&e.id)); + m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects); + m.appearance.effects.retain(|e| !is_pathfinder(&e.id)); m.appearance.items.clear(); m.appearance.contents_index = None; Some(m) @@ -439,7 +440,7 @@ fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> { if carry.moves(&m.appearance) { carry.apply(&mut m.appearance); } - m.appearance.effects.retain(|e| !is_geometry(&e.id)); + m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects); Some((g, rule, ctx, m)) } diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 866d9b32f3..56d9cf601e 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -265,7 +265,7 @@ pub fn effect_catalog() -> Vec { "distort.transform", "Transform…", DT, - "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", + "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once; on, without copies or random, the raster effects listed before this effect (Rasterize and SVG filters excepted) transform with the art whatever strokes says: their sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}), ), g( diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 57f2fc8613..015490a74d 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -265,7 +265,7 @@ fn reshaped(src: &Node, n: &Node, doc: Option<&Document>) -> Node { fn without_geometry_effects(n: &mut Node) { if n.appearance.effects.iter().any(|e| crate::is_geometry(&e.id)) { let mut ap = (*n.appearance).clone(); - ap.effects.retain(|e| !crate::is_geometry(&e.id)); + ap.effects = crate::raster::without_geometry(&ap.effects); n.appearance = ap.into(); } } @@ -794,7 +794,8 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { carry.apply(&mut m.appearance); } } - m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !crate::is_geometry(&e.id)); + m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects); + m.appearance.effects.retain(|e| !is_pathfinder(&e.id)); if paint { let shapes = member_shapes(doc, &m); let bounds = m.geometric_bounds(); diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index cfa51ca2b0..d79aac09cc 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -1,11 +1,13 @@ //! Raster (painted) effects: descriptions for the renderer. -use serde_json::Value; +use std::borrow::Cow; + +use serde_json::{Map, Value, json}; use vectorcraft_doc::color::{BlendMode, Color}; use vectorcraft_doc::rastersettings::MAX_ADD_AROUND; use vectorcraft_doc::{Effect, RasterColorModel, RasterEffectsSettings}; -use vectorcraft_geom::{Point, Rect}; +use vectorcraft_geom::{Affine, Point, Rect}; use crate::merged_params; use crate::pixel::{self, PixelFx, PixelSpace}; @@ -384,9 +386,10 @@ fn opacity(p: &Value) -> f32 { (num(p, "opacity", 75.0) / 100.0).clamp(0.0, 1.0) as f32 } -/// The visible raster effects of `effects`, in stack order. +/// The visible raster effects of `effects`, in stack order, each following the Transform effects +/// listed after it ([`followed`]). pub fn raster_effects(effects: &[Effect]) -> Vec { - effects + followed(effects) .iter() .filter(|e| e.visible) .filter_map(|e| { @@ -426,6 +429,89 @@ pub fn raster_effects(effects: &[Effect]) -> Vec { .collect() } +/// `effects` with each raster effect following the visible Transform effects listed after it +/// ([`follow_transform`]): effects apply in the order they are listed, so a Transform effect +/// transforms what the raster effects before it paint, whatever its Scale Strokes & Effects says. +/// A hidden raster effect follows too, so it draws the same when it is shown after the Transform +/// effect has been expanded. A raster effect that a Transform effect making copies or moving at +/// random follows stays as it is, as does one after the last Transform effect. Borrowed when +/// nothing changes. +pub(crate) fn followed(effects: &[Effect]) -> Cow<'_, [Effect]> { + if !effects.iter().any(|e| e.visible && e.id == TRANSFORM) { + return Cow::Borrowed(effects); + } + let mut out: Option> = None; + // The linear part of the Transform effects after the effect at hand, the last one outermost; + // `None` once one of them makes copies or moves at random. + let mut after = Some(Affine::IDENTITY); + for (i, e) in effects.iter().enumerate().rev() { + if e.id == TRANSFORM { + if !e.visible { + continue; + } + let p = merged_params(&e.id, &e.params); + // With Transform Objects off the art stays where it is. + if flag(&p, "objects", true) { + let (map, copies) = crate::distort::transform_map(Rect::ZERO, &p, 0); + after = after.filter(|_| copies == 0 && !flag(&p, "random", false)).map(|a| a * linear(map)); + } + continue; + } + let Some(a) = after.filter(|a| *a != Affine::IDENTITY && follows(&e.id)) else { continue }; + let list = out.get_or_insert_with(|| effects.to_vec()); + if let Some(e) = list.get_mut(i) { + follow_transform(e, a); + } + } + out.map_or(Cow::Borrowed(effects), Cow::Owned) +} + +/// `effects` without their geometry effects, each raster effect following the Transform effects +/// it loses ([`followed`]): what an object keeps once its geometry effects are applied to its art. +pub(crate) fn without_geometry(effects: &[Effect]) -> Vec { + followed(effects).iter().filter(|e| !crate::is_geometry(&e.id)).cloned().collect() +} + +/// The Transform effect's id. +const TRANSFORM: &str = "distort.transform"; + +/// Does raster effect `id` follow the Transform effects listed after it ([`followed`])? An SVG +/// filter does not, and neither does Rasterize, whose image keeps its own pixel grid and room +/// around the art. +fn follows(id: &str) -> bool { + crate::is_raster(id) && id != crate::SVG_FILTER && id != RASTERIZE +} + +/// `a` without its translation. +fn linear(a: Affine) -> Affine { + let [a0, a1, a2, a3, _, _] = a.as_coeffs(); + Affine::new([a0, a1, a2, a3, 0.0, 0.0]) +} + +/// Make raster effect `e` follow `a`, the linear part of a Transform effect listed after it, as +/// that effect transforms what `e` paints: its distances and raster sizes scale by `a`'s mean +/// scale ([`crate::scale_effect`]), and a Drop Shadow's offset turns, reflects and scales with +/// `a`. Effects that [`follows`] leaves out stay as they are. +fn follow_transform(e: &mut Effect, a: Affine) { + if !follows(&e.id) { + return; + } + let offset = (e.id == "stylize.dropShadow").then(|| { + let p = merged_params(&e.id, &e.params); + a * Point::new(num(&p, "x", 7.0), num(&p, "y", 7.0)) + }); + if let Some(k) = vectorcraft_doc::Scaling::factor(a) { + crate::scale_effect(e, k); + } + if let Some(o) = offset.filter(|o| o.x.is_finite() && o.y.is_finite()) { + if !e.params.is_object() { + e.params = Value::Object(Map::new()); + } + e.params["x"] = json!(o.x); + e.params["y"] = json!(o.y); + } +} + /// How far the visible raster effects of `effects` paint beyond `bounds`, the (effected) /// geometry's, in document units. Geometry effects are accounted for by evaluating them. pub fn outset(effects: &[Effect], bounds: Rect) -> f64 { diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index c629cc10ca..36e34a90b4 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -594,7 +594,7 @@ fn as_art_opts( out.knockout = vectorcraft_doc::Knockout::Off; out.knockout_shape = n.knockout_shape; out.extras_mut().mask = n.extras().mask.clone(); - out.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); + out.appearance.effects = crate::raster::without_geometry(&n.appearance.effects); Some(out) } diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 532251bd73..b1616c676d 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -4,6 +4,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes}; use super::*; +mod rasterfollow; mod scale; mod transformfx; diff --git a/crates/effects/src/tests/rasterfollow.rs b/crates/effects/src/tests/rasterfollow.rs new file mode 100644 index 0000000000..8212c11487 --- /dev/null +++ b/crates/effects/src/tests/rasterfollow.rs @@ -0,0 +1,173 @@ +//! Raster effects listed before a Transform effect follow it: the Transform effect transforms +//! what they paint. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId}; +use vectorcraft_geom::{Rect, shapes}; + +use crate::{RasterFx, bake_document, expand_outlined, raster_effects}; + +fn fx(id: &str, params: Value) -> Effect { + Effect { id: id.into(), params, visible: true } +} + +fn shadow(x: f64, y: f64, blur: f64) -> Effect { + fx("stylize.dropShadow", json!({"x": x, "y": y, "blur": blur})) +} + +fn transform(params: Value) -> Effect { + fx("distort.transform", params) +} + +/// The offset and blur of the first Drop Shadow `effects` draw. +fn drawn_shadow(effects: &[Effect]) -> (f64, f64, f64) { + match raster_effects(effects).first() { + Some(RasterFx::DropShadow { dx, dy, blur, .. }) => (*dx, *dy, *blur), + other => panic!("expected a drop shadow, got {other:?}"), + } +} + +fn close(a: (f64, f64, f64), b: (f64, f64, f64)) -> bool { + (a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9 && (a.2 - b.2).abs() < 1e-9 +} + +/// A 50 pt red square with `effects`. +fn square(id: u64, effects: Vec) -> Node { + let mut n = Node::path( + NodeId(id), + shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), + Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0), + ); + n.appearance.effects = effects; + n +} + +#[test] +fn a_transform_after_a_drop_shadow_scales_its_offset_and_blur() { + for strokes in [false, true] { + let effects = [shadow(10.0, 10.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 200, "strokes": strokes}))]; + assert!(close(drawn_shadow(&effects), (20.0, 20.0, 8.0)), "Scale Strokes & Effects {strokes}: {:?}", drawn_shadow(&effects)); + } +} + +#[test] +fn a_drop_shadow_after_a_transform_keeps_its_offset() { + let effects = [transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})), shadow(10.0, 10.0, 4.0)]; + assert!(close(drawn_shadow(&effects), (10.0, 10.0, 4.0)), "{:?}", drawn_shadow(&effects)); +} + +#[test] +fn the_offset_follows_an_uneven_scale_a_rotation_and_a_reflection() { + let uneven = [shadow(10.0, 10.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 100}))]; + assert!(close(drawn_shadow(&uneven), (20.0, 10.0, 4.0 * 2f64.sqrt())), "{:?}", drawn_shadow(&uneven)); + // A turn of 90° counterclockwise on screen takes an offset to the right to one upward. + let turned = [shadow(10.0, 0.0, 4.0), transform(json!({"rotate": 90}))]; + let (dx, dy, blur) = drawn_shadow(&turned); + assert!(dx.abs() < 1e-9 && (dy + 10.0).abs() < 1e-9 && (blur - 4.0).abs() < 1e-9, "{dx} {dy} {blur}"); + let reflected = [shadow(10.0, 0.0, 4.0), transform(json!({"reflectX": true}))]; + assert!(close(drawn_shadow(&reflected), (-10.0, 0.0, 4.0)), "{:?}", drawn_shadow(&reflected)); +} + +#[test] +fn transforms_after_the_shadow_apply_in_order() { + let effects = [shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200})), transform(json!({"rotate": 90}))]; + let (dx, dy, _) = drawn_shadow(&effects); + assert!(dx.abs() < 1e-9 && (dy + 20.0).abs() < 1e-9, "{dx} {dy}"); + // Only the Transform after a shadow moves it. + let between = [shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200})), shadow(0.0, 10.0, 0.0)]; + let drawn = raster_effects(&between); + let offsets: Vec<(f64, f64)> = drawn + .iter() + .filter_map(|r| match r { + RasterFx::DropShadow { dx, dy, .. } => Some((*dx, *dy)), + _ => None, + }) + .collect(); + assert_eq!(offsets, vec![(20.0, 0.0), (0.0, 10.0)]); +} + +#[test] +fn transforms_that_leave_the_art_or_copy_it_leave_the_shadow() { + for params in [ + json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true}), + json!({"scaleH": 200, "scaleV": 200, "copies": 1}), + json!({"scaleH": 200, "scaleV": 200, "random": true}), + ] { + let effects = [shadow(10.0, 10.0, 4.0), transform(params.clone())]; + assert!(close(drawn_shadow(&effects), (10.0, 10.0, 4.0)), "{params}: {:?}", drawn_shadow(&effects)); + } + let mut hidden = transform(json!({"scaleH": 200, "scaleV": 200})); + hidden.visible = false; + let effects = [shadow(10.0, 10.0, 4.0), hidden]; + assert!(close(drawn_shadow(&effects), (10.0, 10.0, 4.0)), "{:?}", drawn_shadow(&effects)); +} + +/// A hidden shadow kept when the Transform effect is applied (Expand Appearance) draws as it would +/// have before, once it is shown. +#[test] +fn a_hidden_shadow_follows_the_transform_it_loses() { + let mut hidden = shadow(10.0, 10.0, 4.0); + hidden.visible = false; + let kept = crate::raster::without_geometry(&[hidden, transform(json!({"scaleH": 200, "scaleV": 200}))]); + assert_eq!(kept.len(), 1); + let mut shown = kept[0].clone(); + shown.visible = true; + assert!(close(drawn_shadow(&[shown]), (20.0, 20.0, 8.0))); +} + +/// Rasterize keeps its room around the art: its image keeps its own pixel grid. +#[test] +fn rasterize_before_a_transform_keeps_its_options() { + let rasterize = fx(crate::RASTERIZE, json!({"addAround": 6})); + let effects = [rasterize.clone(), transform(json!({"scaleH": 200, "scaleV": 200}))]; + assert_eq!(crate::raster::followed(&effects)[0], rasterize); + assert_eq!(crate::raster::without_geometry(&effects), vec![rasterize]); +} + +#[test] +fn glows_blurs_and_feathers_scale_with_the_transform_after_them() { + let effects = [ + fx("stylize.outerGlow", json!({"blur": 5})), + fx("stylize.innerGlow", json!({"blur": 5})), + fx("stylize.feather", json!({"radius": 5})), + fx("blur.gaussian", json!({"radius": 5})), + transform(json!({"scaleH": 50, "scaleV": 50})), + ]; + let sizes: Vec = raster_effects(&effects) + .iter() + .map(|r| match r { + RasterFx::OuterGlow { blur, .. } | RasterFx::InnerGlow { blur, .. } => *blur, + RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius, .. } => *radius, + other => panic!("unexpected {other:?}"), + }) + .collect(); + assert_eq!(sizes, vec![2.5; 4]); + // How far the shadow paints past the object follows too. + let shadowed = [shadow(10.0, 10.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200}))]; + assert_eq!(crate::outset(&shadowed, Rect::new(0.0, 0.0, 100.0, 100.0)), 20.0); +} + +#[test] +fn expanded_and_exported_objects_keep_the_shadow_the_canvas_draws() { + let effects = vec![shadow(10.0, 10.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 200}))]; + // A path drawn through its art (Scale Strokes & Effects on), expanded. + let mut with_options = effects.clone(); + with_options[1] = transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})); + let n = square(7, with_options); + let expanded = expand_outlined(None, &n, None).unwrap(); + assert!(close(drawn_shadow(&expanded.appearance.effects), (20.0, 20.0, 8.0)), "{:?}", expanded.appearance.effects); + // A path and a group, baked for export. + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, square(8, effects.clone())).unwrap(); + let mut g = Node::group(NodeId(9), vec![std::sync::Arc::new(square(10, vec![]))]); + g.appearance.effects = effects.clone(); + d.insert(Some(layer), 1, g).unwrap(); + let baked = bake_document(&d).unwrap(); + for id in [8, 9] { + let b = baked.node(NodeId(id)).unwrap(); + assert!(!b.appearance.effects.iter().any(|e| e.id == "distort.transform"), "{id}: {:?}", b.appearance.effects); + assert!(close(drawn_shadow(&b.appearance.effects), (20.0, 20.0, 8.0)), "{id}: {:?}", b.appearance.effects); + } +} diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index c03a6cc0f3..96bddc17f1 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -307,6 +307,36 @@ fn drop_shadow_darkens_outside_the_shape() { assert_eq!(lum(img.pixel(15, 15)), WHITE); } +/// A Transform effect listed after a Drop Shadow transforms the shadow with the art: halving a +/// 40 pt square about its center halves the shadow's 10 pt offset, with Scale Strokes & Effects +/// off or on, and for a group as for a path. +#[test] +fn a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art() { + let r = Rect::new(20.0, 20.0, 60.0, 60.0); + let shadow = ("stylize.dropShadow", json!({"x": 10, "y": 10, "blur": 0, "opacity": 100, "mode": "normal"})); + for strokes in [false, true] { + let transform = ("distort.transform", json!({"scaleH": 50, "scaleV": 50, "strokes": strokes})); + let path = doc_with(r, Color::rgb(1.0, 0.0, 0.0), vec![shadow.clone(), transform.clone()]); + let mut group = doc_with(r, Color::rgb(1.0, 0.0, 0.0), vec![]); + let l = group.layers[0].id; + let square = group.layers[0].children().and_then(|c| c.first()).cloned().unwrap(); + group.remove(square.id).unwrap(); + let mut g = Node::group(group.alloc_id(), vec![square]); + g.appearance.effects = [shadow.clone(), transform].into_iter().map(|(id, p)| Effect { id: id.into(), params: p, visible: true }).collect(); + group.insert(Some(l), 0, g).unwrap(); + for (what, d) in [("path", path), ("group", group)] { + let img = render(&d); + // The square now covers 30..50 and its shadow 35..55. + assert_eq!(img.pixel(40, 40), [255, 0, 0, 255], "{what}, Scale Strokes & Effects {strokes}"); + assert!(lum(img.pixel(53, 37)) < 100, "{what}, {strokes}: the shadow reaches up to 35: {:?}", img.pixel(53, 37)); + assert_eq!(lum(img.pixel(57, 57)), WHITE, "{what}, {strokes}: the shadow ends at 55"); + // The area redrawn for it takes in the 5 pt shadow around the 30..50 square. + let b = super::cull_bounds(&d.symbol_boxes(), &d.layers[0].children().unwrap()[0]).unwrap(); + assert!((b.x0 - 25.0).abs() < 1e-9 && (b.x1 - 55.0).abs() < 1e-9, "{what}, {strokes}: {b:?}"); + } + } +} + /// A red square's drop shadow at Darkness `darkness` (%), opaque and sharp, 10 pt down and /// right; `shade` "color" or "darkness". fn shaded_doc(mode: vectorcraft_doc::ColorMode, fill: Color, shade: &str, darkness: f64) -> Document { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 351ade4468..b20aadde45 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -589,6 +589,7 @@ since 2026-10-01. - **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. +- **Raster effects before a Transform effect:** effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)), so a Drop Shadow, glow, blur, Feather or pixel filter listed before a Transform effect is transformed with the art, with Scale Strokes & Effects on or off: its sizes scale by the mean scale, and a Drop Shadow's offset turns, reflects and scales with the art. Before, it was drawn at its own distances around the transformed art. The canvas, export and Expand Appearance agree (effects `tests::rasterfollow`, render `a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art`). Not yet: a Transform effect that makes copies or moves at random leaves the raster effects before it as they are, and a Rasterize effect before a Transform effect keeps its pixel grid and room around the art. Scores and hours unchanged. - **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim. - **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. From 88841aa80357fcac6b932dec3c9c873256e602f5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:16:43 -0800 Subject: [PATCH 16/92] Window menu: a panel's entry shows the panel Tab hid instead of closing it The Window menu checked a panel whenever it was in front in the layout, even while Tab, Shift+Tab or Presentation Mode hid every panel, so choosing it, or pressing its function key, closed the panel it was meant to show. - A new panel_docking::visible adds the dock and Presentation Mode to showing. The Window menu's check marks and the toggle use it, and window.panel puts a flyout or floating panel away only while it can be seen. With the dock hidden, the dock shows again with the panel in its earlier place; the toolbar and the Control bar stay as they are. - In Presentation Mode window.panel fails with "panels can't be shown in Presentation Mode" and changes nothing. - reveal_panel, the Gradient and Magic Wand tool double-click, the Character field shortcuts and the Corner and Shared Edge Merge tool items show a panel the dock hid instead of reporting it open or closing a floating Anchor Inspector. Docs: window.panel params text, docs/mcp.md, docs/target-app-parity.md. --- crates/mcp/src/ui_catalog.rs | 2 +- crates/ui-egui/src/addons/pathtools/corner.rs | 2 +- .../src/addons/pathtools/shared_edge.rs | 2 +- crates/ui-egui/src/addons/pathtools/tests.rs | 18 ++ crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/menus/state.rs | 2 +- crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/panel_docking.rs | 35 +++- crates/ui-egui/src/panel_docking/tests.rs | 160 ++++++++++++++++++ crates/ui-egui/src/panels/character.rs | 4 +- crates/ui-egui/src/toolbar.rs | 3 +- crates/ui-egui/src/toolbar/tests.rs | 9 + docs/mcp.md | 5 + docs/target-app-parity.md | 1 + 35 files changed, 250 insertions(+), 17 deletions(-) diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index b07964b225..cc18980e4d 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -594,7 +594,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.panel", "Show Panel", "", - "{panel: id, toggle?: bool} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too); toggle (the Window menu) hides a panel that shows, and shows it again where it was", + "{panel: id, toggle?: bool} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too); toggle (the Window menu) hides a panel that can be seen, and shows it again where it was; with the dock hidden by Tab or Shift+Tab, the dock shows again with the panel in front, leaving the toolbar and Control bar as they are; fails and changes nothing in Presentation Mode, which hides every panel", ), ( "window.panel.layout", diff --git a/crates/ui-egui/src/addons/pathtools/corner.rs b/crates/ui-egui/src/addons/pathtools/corner.rs index 36ee1bd1c9..ace9be954c 100644 --- a/crates/ui-egui/src/addons/pathtools/corner.rs +++ b/crates/ui-egui/src/addons/pathtools/corner.rs @@ -20,7 +20,7 @@ const FIELD_W: f32 = 96.0; /// Object › Plug-ins › Path Precision Tools › Corner Tool. pub fn open(app: &mut VectorcraftApp) -> Result { app.select_tool(ID); - app.run("window.panel", json!({ "panel": super::ID })) + crate::panel_docking::reveal_panel(app, super::ID) } /// How many corners Apply would edit: the selected paths' direct-selected ones, or all of a path diff --git a/crates/ui-egui/src/addons/pathtools/shared_edge.rs b/crates/ui-egui/src/addons/pathtools/shared_edge.rs index 2e1365ffa0..d682aad776 100644 --- a/crates/ui-egui/src/addons/pathtools/shared_edge.rs +++ b/crates/ui-egui/src/addons/pathtools/shared_edge.rs @@ -18,7 +18,7 @@ const LABEL_W: f32 = 80.0; /// Object › Plug-ins › Path Precision Tools › Shared Edge Merge Tool. pub fn open(app: &mut VectorcraftApp) -> Result { app.select_tool(ID); - app.run("window.panel", json!({ "panel": super::ID })) + crate::panel_docking::reveal_panel(app, super::ID) } pub(super) fn section(app: &mut VectorcraftApp, ui: &mut Ui) { diff --git a/crates/ui-egui/src/addons/pathtools/tests.rs b/crates/ui-egui/src/addons/pathtools/tests.rs index d477fa0363..9ecf12062d 100644 --- a/crates/ui-egui/src/addons/pathtools/tests.rs +++ b/crates/ui-egui/src/addons/pathtools/tests.rs @@ -142,6 +142,24 @@ fn the_corner_tool_item_selects_the_tool_and_opens_its_panel_section() { assert!(!vectorcraft_tools::TOOL_GROUPS.iter().flat_map(|g| g.iter()).any(|t| t.id == "cornerTool" || t.id == "slideAnchor")); } +/// The Corner Tool and Shared Edge Merge Tool items leave a floating Anchor Inspector showing +/// instead of closing it, and in Presentation Mode they choose the tool without failing. +#[test] +fn the_tool_items_keep_a_floating_anchor_inspector_showing() { + let mut app = app(); + app.run("window.panel.float", json!({ "panel": ID })).unwrap(); + assert!(crate::panel_docking::visible(&app.ui, ID)); + for command in [super::corner::COMMAND, super::shared_edge::COMMAND] { + app.run(command, json!({})).unwrap(); + assert!(crate::panel_docking::visible(&app.ui, ID), "{command} leaves the panel showing"); + } + app.ui.screen_mode = 3; + app.run(super::corner::COMMAND, json!({})).unwrap(); + assert_eq!(app.session.tool_id(), "cornerTool"); + app.ui.screen_mode = 0; + assert!(crate::panel_docking::visible(&app.ui, ID), "the panel shows when Presentation Mode ends"); +} + #[test] fn the_shared_edge_item_selects_the_tool_and_opens_its_panel_section() { let mut app = app(); diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index cd6dfad587..1ab64a27ba 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6046,6 +6046,7 @@ digits Malayalam Malaiàlam @msg unknown option `{path}.{k}` opció desconeguda `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) sobreimpressions desconegudes `{o}` (preserve, simulate o discard) @msg unknown panel `{raw}` panell desconegut `{raw}` +@msg panels can't be shown in Presentation Mode els panells no es poden mostrar en el mode de presentació @msg unknown parameter `{key}`; did you mean `{near}`? paràmetre desconegut `{key}`; volies dir `{near}`? @msg unknown parameter `{key}`; the command takes no parameters paràmetre desconegut `{key}`; l’ordre no admet cap paràmetre @msg unknown parameter `{key}`; the command takes: {keys} paràmetre desconegut `{key}`; l’ordre admet: {keys} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 00de3e7057..84dcd7f933 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5089,6 +5089,7 @@ imageTrace Outline Obrys @msg unknown option `{path}.{k}` neznámá možnost `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) neznámé přetisky `{o}` (preserve, simulate nebo discard) @msg unknown panel `{raw}` neznámý panel `{raw}` +@msg panels can't be shown in Presentation Mode v režimu prezentace nelze panely zobrazit @msg unknown preference `{key}` neznámá předvolba `{key}` @msg Preferences imported. Předvolby byly importovány. @msg not a preferences file: {e} nejde o soubor předvoleb: {e} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0de0dce0b9..9748e9d695 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4706,6 +4706,7 @@ theme Appearance Erscheinungsbild @msg unknown option `{path}.{k}` unbekannte Option `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) unbekannter Wert für overprints `{o}` (preserve, simulate oder discard) @msg unknown panel `{raw}` unbekanntes Bedienfeld `{raw}` +@msg panels can't be shown in Presentation Mode im Präsentationsmodus können keine Bedienfelder angezeigt werden @msg unknown preference `{key}` unbekannte Voreinstellung `{key}` @msg Preferences imported. Voreinstellungen importiert. @msg not a preferences file: {e} keine Voreinstellungsdatei: {e} diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index a75edff7a8..a30dce988f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4730,6 +4730,7 @@ digits Malayalam Μαλαγιάλαμ @msg unknown option `{path}.{k}` άγνωστη επιλογή `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) άγνωστη επιλογή υπερεκτύπωσης `{o}` (preserve, simulate ή discard) @msg unknown panel `{raw}` άγνωστος πίνακας `{raw}` +@msg panels can't be shown in Presentation Mode τα πλαίσια δεν μπορούν να εμφανιστούν στη λειτουργία παρουσίασης @msg unknown preference `{key}` άγνωστη προτίμηση `{key}` @msg Preferences imported. Οι προτιμήσεις εισήχθησαν. @msg not a preferences file: {e} δεν είναι αρχείο προτιμήσεων: {e} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6d548f6743..283bd1b2a6 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4781,6 +4781,7 @@ axis Both Ambos @msg unknown option `{path}.{k}` opción desconocida `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) sobreimpresiones desconocidas `{o}` (preserve, simulate o discard) @msg unknown panel `{raw}` panel desconocido `{raw}` +@msg panels can't be shown in Presentation Mode los paneles no se pueden mostrar en el modo de presentación @msg unknown preference `{key}` preferencia desconocida `{key}` @msg Preferences imported. Preferencias importadas. @msg not a preferences file: {e} no es un archivo de preferencias: {e} diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 7ae54d2bef..d33eaa6432 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4991,6 +4991,7 @@ imageTrace Outline Ääriviiva @msg unknown option `{path}.{k}` tuntematon asetus `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) tuntematon päällepainatusten käsittely `{o}` (preserve, simulate tai discard) @msg unknown panel `{raw}` tuntematon paneeli `{raw}` +@msg panels can't be shown in Presentation Mode paneeleja ei voi näyttää esitystilassa @msg unknown preference `{key}` tuntematon asetus `{key}` @msg Preferences imported. Asetukset tuotiin. @msg not a preferences file: {e} ei ole asetustiedosto: {e} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 35c649f895..df839a9d93 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4770,6 +4770,7 @@ axis Both Les deux @msg unknown option `{path}.{k}` option inconnue `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) surimpressions inconnues `{o}` (preserve, simulate ou discard) @msg unknown panel `{raw}` panneau inconnu `{raw}` +@msg panels can't be shown in Presentation Mode les panneaux ne peuvent pas être affichés en mode Présentation @msg unknown preference `{key}` préférence inconnue `{key}` @msg Preferences imported. Préférences importées. @msg not a preferences file: {e} ce n’est pas un fichier de préférences : {e} diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 335b0c99f0..392722c6d3 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8020,6 +8020,7 @@ antiAlias Sharp शार्प @msg unknown overprints `{v}` (preserve, simulate or discard) अज्ञात ओवरप्रिंट `{v}` (प्ररक्षित, अनुकरण या त्यागपत्र) @msg unknown panel अज्ञात पैनल @msg unknown panel `{raw}` अज्ञात पैनल `{raw}` +@msg panels can't be shown in Presentation Mode प्रस्तुति मोड में पैनल्स नहीं दिखाए जा सकते @msg unknown parameter `{key}`; did you mean `{near}`? अज्ञात पैरामीटर `{key}`; क्या आपको `{near}` का मतलब है? @msg unknown parameter `{key}`; the command takes no parameters अज्ञात पैरामीटर `{key}`; कमांड कोई पैरामीटर नहीं लेता है @msg unknown parameter `{key}`; the command takes: {keys} अज्ञात पैरामीटर `{key}`; कमांड लेता है: {keys} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c3ed22bce8..d3d6e5f5e8 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5594,6 +5594,7 @@ imageTrace Outline Körvonal @msg unknown option `{path}.{k}` ismeretlen beállítás: `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) ismeretlen felülnyomás-kezelés: `{o}` (preserve, simulate vagy discard) @msg unknown panel `{raw}` ismeretlen panel: `{raw}` +@msg panels can't be shown in Presentation Mode bemutató módban a panelek nem jeleníthetők meg @msg unknown preference `{key}` ismeretlen beállítás: `{key}` @msg Preferences imported. A beállítások importálva. @msg not a preferences file: {e} nem beállításfájl: {e} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a6edc31792..101ebc9145 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4832,6 +4832,7 @@ axis Both Entrambi @msg unknown option `{path}.{k}` opzione sconosciuta `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) valore di sovrastampa sconosciuto `{o}` (preserve, simulate o discard) @msg unknown panel `{raw}` pannello sconosciuto `{raw}` +@msg panels can't be shown in Presentation Mode i pannelli non possono essere mostrati in modalità presentazione @msg unknown preference `{key}` preferenza sconosciuta `{key}` @msg Preferences imported. Preferenze importate. @msg not a preferences file: {e} non è un file di preferenze: {e} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 65d7721aa1..42d77f68ad 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4861,6 +4861,7 @@ swatch Bright Violet ビビッドバイオレット @msg give only one of onto, below and above onto、below、above のうち 1 つだけを指定してください @msg column must be true or a number from 1 column には true または 1 以上の数値を指定してください @msg unknown panel `{raw}` `{raw}` は不明なパネルです +@msg panels can't be shown in Presentation Mode プレゼンテーションモードではパネルを表示できません @msg x and y must both be numbers x と y はどちらも数値で指定してください @msg Downloaded {what} {what} をダウンロードしました @msg Imported {what}: {_1} {what} を読み込みました: {_1} diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index eb686a14a0..76e439f180 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4526,6 +4526,7 @@ font-technology Variable ცვლადი @msg unknown option `{path}.{k}` უცნობი პარამეტრი `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) უცნობი ზედდაბეჭდვა `{o}` (preserve, simulate ან discard) @msg unknown panel `{raw}` უცნობი პანელი `{raw}` +@msg panels can't be shown in Presentation Mode პრეზენტაციის რეჟიმში პანელების ჩვენება შეუძლებელია @msg unknown preference `{key}` უცნობი პარამეტრი `{key}` @msg Preferences imported. პარამეტრები იმპორტირებულია. @msg not a preferences file: {e} ეს არ არის პარამეტრების ფაილი: {e} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 52a10ab8e8..5ea2f4412c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4788,6 +4788,7 @@ theme Appearance 외관 @msg give only one of onto, below and above onto, below, above 중 하나만 지정하세요 @msg column must be true or a number from 1 column은 true이거나 1 이상의 숫자여야 합니다 @msg unknown panel `{raw}` 알 수 없는 패널 `{raw}` +@msg panels can't be shown in Presentation Mode 프레젠테이션 모드에서는 패널을 표시할 수 없습니다 @msg x and y must both be numbers x와 y는 모두 숫자여야 합니다 @msg Downloaded {what} {what}을(를) 다운로드했습니다 @msg Imported {what}: {_1} {what}을(를) 가져왔습니다: {_1} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e44328480f..f2f185b826 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4915,6 +4915,7 @@ imageTrace Outline Kontur @msg unknown option `{path}.{k}` nieznana opcja `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) nieznana wartość nadruków `{o}` (preserve, simulate lub discard) @msg unknown panel `{raw}` nieznany panel `{raw}` +@msg panels can't be shown in Presentation Mode w trybie prezentacji nie można wyświetlić paneli @msg unknown preference `{key}` nieznana preferencja `{key}` @msg Preferences imported. Zaimportowano preferencje. @msg not a preferences file: {e} to nie jest plik preferencji: {e} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3ce111f7a4..8392d9a65c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4318,6 +4318,7 @@ axis Both Ambos @msg unknown option `{path}.{k}` opção desconhecida `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) sobreimpressões desconhecidas `{o}` (preserve, simulate ou discard) @msg unknown panel `{raw}` painel desconhecido `{raw}` +@msg panels can't be shown in Presentation Mode os painéis não podem ser mostrados no Modo de Apresentação @msg unknown preference `{key}` preferência desconhecida `{key}` @msg Preferences imported. Preferências importadas. @msg not a preferences file: {e} não é um arquivo de preferências: {e} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c93807c08a..f1d0376e06 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4818,6 +4818,7 @@ axis Both Оба @msg unknown option `{path}.{k}` неизвестный параметр `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) неизвестные наложения `{o}` (preserve, simulate или discard) @msg unknown panel `{raw}` неизвестная панель `{raw}` +@msg panels can't be shown in Presentation Mode в режиме презентации панели показать нельзя @msg unknown preference `{key}` неизвестная настройка `{key}` @msg Preferences imported. Настройки импортированы. @msg not a preferences file: {e} не является файлом настроек: {e} diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ce20b6c72d..e176c12e78 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4678,6 +4678,7 @@ axis Both Båda @msg unknown option `{path}.{k}` okänt alternativ `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) okänt värde för övertryck `{o}` (preserve, simulate eller discard) @msg unknown panel `{raw}` okänd panel `{raw}` +@msg panels can't be shown in Presentation Mode paneler kan inte visas i presentationsläge @msg unknown preference `{key}` okänd inställning `{key}` @msg Preferences imported. Inställningarna har importerats. @msg not a preferences file: {e} inte en inställningsfil: {e} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index db3c4501b7..5ff5ee4daa 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4528,6 +4528,7 @@ axis Both Her İkisi @msg unknown option `{path}.{k}` bilinmeyen seçenek `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) bilinmeyen üst baskılar `{o}` (koru, benzet veya at) @msg unknown panel `{raw}` bilinmeyen panel `{raw}` +@msg panels can't be shown in Presentation Mode Sunum Modunda paneller gösterilemez @msg unknown preference `{key}` bilinmeyen tercih `{key}` @msg Preferences imported. Tercihler içe aktarıldı. @msg not a preferences file: {e} tercih dosyası değil: {e} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 151a886cf9..0b95dffcfe 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4816,6 +4816,7 @@ axis Both Обидві @msg unknown option `{path}.{k}` невідомий параметр `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) невідомі накладення `{o}` (preserve, simulate або discard) @msg unknown panel `{raw}` невідома панель `{raw}` +@msg panels can't be shown in Presentation Mode у режимі презентації панелі показати не можна @msg unknown preference `{key}` невідоме налаштування `{key}` @msg Preferences imported. Налаштування імпортовано. @msg not a preferences file: {e} не є файлом налаштувань: {e} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f3b505c648..dd53145f9b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5262,6 +5262,7 @@ theme Appearance Giao diện @msg unknown option `{path}.{k}` tùy chọn không xác định `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) chế độ in đè không xác định `{o}` (preserve, simulate hoặc discard) @msg unknown panel `{raw}` bảng không xác định `{raw}` +@msg panels can't be shown in Presentation Mode không thể hiển thị bảng trong chế độ trình bày @msg unknown parameter `{key}`; did you mean `{near}`? tham số không xác định `{key}`; ý bạn là `{near}`? @msg unknown parameter `{key}`; the command takes no parameters tham số không xác định `{key}`; lệnh này không nhận tham số @msg unknown parameter `{key}`; the command takes: {keys} tham số không xác định `{key}`; lệnh nhận: {keys} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c7219b9448..b8f4f5eccb 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4556,6 +4556,7 @@ imageTrace Outline 轮廓 @msg give only one of onto, below and above onto、below 和 above 只能给出一个 @msg column must be true or a number from 1 column 必须是 true 或从 1 开始的数字 @msg unknown panel `{raw}` 未知面板 `{raw}` +@msg panels can't be shown in Presentation Mode 演示模式下无法显示面板 @msg x and y must both be numbers x 和 y 都必须是数字 @msg Downloaded {what} 已下载 {what} @msg Imported {what}: {_1} 已导入 {what}:{_1} diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 1c753576a5..26e0f6fa38 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2349,6 +2349,7 @@ file-filter Templates 範本 @msg give only one of onto, below and above onto、below 和 above 只能提供其中一個 @msg column must be true or a number from 1 column 必須是 true 或從 1 起算的數字 @msg unknown panel `{raw}` 未知的面板 `{raw}` +@msg panels can't be shown in Presentation Mode 簡報模式下無法顯示面板 @msg x and y must both be numbers x 和 y 都必須是數字 @msg Downloaded {what} 已下載 {what} @msg Imported {what}: {_1} 已匯入 {what}:{_1} diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index d68608824b..be40dc44cb 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -96,7 +96,7 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { "window.learn" => crate::panels::learn::visible(&app.ui), "window.panel" => { let panel = p.get("panel").and_then(Value::as_str).unwrap_or(""); - normalize_panel(panel).is_some_and(|id| crate::panel_docking::showing(&app.ui, id)) + normalize_panel(panel).is_some_and(|id| crate::panel_docking::visible(&app.ui, id)) } "window.collapseDock" => crate::panel_docking::dock_collapsed(&app.ui), "window.workspace" => p.get("name").and_then(Value::as_str) == Some(app.ui.workspace.as_str()), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index f435bfff22..5aa591f114 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -595,7 +595,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.panel", "Show Panel", "", - "{panel: id, toggle?: bool} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too); toggle (the Window menu) hides a panel that shows, and shows it again where it was", + "{panel: id, toggle?: bool} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too); toggle (the Window menu) hides a panel that can be seen, and shows it again where it was; with the dock hidden by Tab or Shift+Tab, the dock shows again with the panel in front, leaving the toolbar and Control bar as they are; fails and changes nothing in Presentation Mode, which hides every panel", ), ( "window.panel.layout", diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index ef502761df..887c4cc83c 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -270,6 +270,12 @@ pub(crate) fn showing(ui: &UiState, panel: &str) -> bool { (ui.open_panel.as_deref() == Some(panel) && iconic) || (!iconic && layout.panel_state(&panel.to_string()) == craft_ui::docking::PanelState::Front) } +/// Can `panel` be seen: [`showing`] while the dock is shown (Tab and Shift+Tab hide it) and +/// Presentation Mode is off? The Window menu's check marks follow this. +pub(crate) fn visible(ui: &UiState, panel: &str) -> bool { + ui.dock && ui.screen_mode < 3 && showing(ui, panel) +} + /// Can either panel that uses row thumbnails be seen, including in a floating window or flyout? /// An iconic group's open flyout is named for the icon that opened it; its active tab can differ. fn thumbnail_panel_showing(ui: &UiState, ctx: &egui::Context) -> bool { @@ -343,19 +349,31 @@ pub(crate) fn command(app: &mut VectorcraftApp, id: &str, params: &Value) -> Opt /// `window.panel {panel}`: show a panel. One in an icon column opens (or puts away) its flyout; /// one in front of its floating window hides; one in a group comes to the front; one closed with a remembered place comes back there; a -/// library panel or a panel the dock has no place for floats. +/// library panel or a panel the dock has no place for floats. A flyout or floating panel is put +/// away only while it can be seen ([`visible`]): with the dock hidden by Tab or Shift+Tab, the +/// dock shows again with the panel where it was, and the toolbar and Control bar stay as they are. +/// Presentation Mode hides every panel, so there the command fails and changes nothing. fn show_panel(app: &mut VectorcraftApp, params: &Value) -> Result { let raw = params.get("panel").and_then(Value::as_str).unwrap_or_default(); let panel = normalize(raw).ok_or_else(|| format!("unknown panel `{raw}`"))?; + if app.ui.screen_mode >= 3 { + return Err("panels can't be shown in Presentation Mode".into()); + } + place_panel(app, panel) +} + +/// [`show_panel`] for a known panel, in any screen mode. +fn place_panel(app: &mut VectorcraftApp, panel: &str) -> Result { + let was_visible = visible(&app.ui, panel); let layout = layout_of(&app.ui); app.ui.dock = true; if in_iconic_column(&layout, panel) { - app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(panel) { None } else { Some(panel.into()) }; + app.ui.open_panel = if was_visible { None } else { Some(panel.into()) }; return Ok(json!({"open": app.ui.open_panel})); } // A floating panel in front of its window hides again, as an icon panel's flyout does: its // shortcut shows and hides it. - if floating_window(&layout, panel).is_some() && layout.panel_state(&panel.to_string()) == craft_ui::docking::PanelState::Front { + if was_visible && floating_window(&layout, panel).is_some() { return shared_command(app, "window.panel.close", &json!({"panel": panel})); } if !layout.contains(&panel.to_string()) && !app.ui.docking_hidden.contains_key(panel) && crate::panels::library_panel::parse(panel).is_some() { @@ -404,14 +422,15 @@ fn restore_hidden_group(app: &mut VectorcraftApp, panel: &str) -> Result Result { - if showing(&app.ui, panel) { - app.ui.dock = true; + if visible(&app.ui, panel) { return Ok(json!({"open": panel})); } - show_panel(app, &json!({ "panel": panel })) + let id = normalize(panel).ok_or_else(|| format!("unknown panel `{panel}`"))?; + place_panel(app, id) } /// `window.collapseDock {collapsed?}`: collapse every dock column to icons, or expand the outer diff --git a/crates/ui-egui/src/panel_docking/tests.rs b/crates/ui-egui/src/panel_docking/tests.rs index 2d33420b9e..3e2cf999e8 100644 --- a/crates/ui-egui/src/panel_docking/tests.rs +++ b/crates/ui-egui/src/panel_docking/tests.rs @@ -346,6 +346,28 @@ fn a_character_field_shortcut_brings_the_floating_character_panel_forward() { } } +/// With the panels hidden by Tab, a Character field shortcut shows the dock with the floating +/// Character panel, so the field it focuses can be seen. +#[test] +fn a_character_field_shortcut_shows_the_panels_tab_hid() { + let mut h = Harness::new(); + let id = h.app.run("text.create", json!({"x": 20, "y": 50, "text": "AVAX"})).unwrap()["id"].clone(); + h.app.run("select.set", json!({"ids": [id]})).unwrap(); + h.app.run("window.panel.float", json!({"panel": "character"})).unwrap(); + h.frame(vec![]); + let rect = floating_window(&h.layout(), "character").map(|w| w.rect); + h.app.run("window.dock", json!({})).unwrap(); + assert!(!visible(&h.app.ui, "character")); + h.app.run("type.highlightKerning", json!({})).unwrap(); + assert!(h.app.ui.dock && visible(&h.app.ui, "character"), "the panel shows"); + assert_eq!(floating_window(&h.layout(), "character").map(|w| w.rect), rect, "where it was"); + // In Presentation Mode the shortcut still focuses the field, and the panel shows when the mode ends. + h.app.ui.screen_mode = 3; + assert_eq!(h.app.run("type.highlightTracking", json!({})).unwrap(), json!({"field": "tracking"})); + h.app.ui.screen_mode = 0; + assert!(visible(&h.app.ui, "character"), "the panel stays in place"); +} + #[test] fn the_dock_collapses_to_icons_and_back() { let mut h = Harness::new(); @@ -1112,6 +1134,144 @@ fn panel_function_keys_toggle_icon_flyouts_and_floating_groups() { assert!(!showing(&h.app.ui, "layers"), "the expanded dock also toggles"); } +/// Window › Layers: the menu item's command and params. +fn window_layers() -> Value { + json!({"panel": "layers", "toggle": true}) +} + +fn checked(h: &Harness, params: &Value) -> Option { + crate::menus::checked(&h.app, "window.panel", params) +} + +/// With the panels hidden by Tab, Window › Layers has no check mark, and choosing it shows the dock +/// with Layers where it was instead of closing the tab; the toolbar and the Control bar stay hidden +/// until the next Tab shows them. +#[test] +fn window_panel_from_tab_hidden_dock_reveals_named_front_tab() { + let mut h = Harness::new(); + let item = window_layers(); + h.app.run("window.panel", json!({"panel": "layers"})).unwrap(); + assert_eq!(checked(&h, &item), Some(true)); + let before = h.layout(); + h.app.ui.toolbar = true; + h.app.ui.control_bar = true; + h.app.run("window.dock", json!({})).unwrap(); + assert!(!h.app.ui.dock && !h.app.ui.toolbar && !h.app.ui.control_bar); + assert_eq!(checked(&h, &item), Some(false), "no check mark while Tab hides the panels"); + h.app.run("window.panel", item.clone()).unwrap(); + assert!(h.app.ui.dock && visible(&h.app.ui, "layers"), "Layers shows"); + assert_eq!(h.layout(), before, "in its place"); + assert!(!h.app.ui.toolbar && !h.app.ui.control_bar, "the toolbar and the Control bar stay hidden"); + assert_eq!(checked(&h, &item), Some(true)); + h.app.run("window.dock", json!({})).unwrap(); + assert!(h.app.ui.dock && h.app.ui.toolbar && h.app.ui.control_bar, "the next Tab shows the bars the first Tab hid"); + h.app.run("window.panel", item.clone()).unwrap(); + assert!(!h.layout().contains(&"layers".to_string()), "a visible panel closes"); + assert!(h.layout().contains(&"properties".to_string()), "only the panel named"); + assert_eq!(checked(&h, &item), Some(false)); +} + +/// With the panels hidden by Shift+Tab, Window › Layers and its plain command show a floating +/// Layers panel again where it was instead of closing it. +#[test] +fn window_panel_from_shift_tab_hidden_floating_window_reveals_it() { + let mut h = Harness::new(); + let item = window_layers(); + h.app.run("window.panel.float", json!({"panel": "layers"})).unwrap(); + h.frame(vec![]); + let rect = floating_window(&h.layout(), "layers").map(|w| w.rect); + assert!(rect.is_some()); + h.app.ui.toolbar = true; + h.app.ui.control_bar = true; + for params in [item.clone(), json!({"panel": "layers"})] { + h.app.run("window.panels", json!({})).unwrap(); + assert!(!h.app.ui.dock && h.app.ui.toolbar && h.app.ui.control_bar); + assert_eq!(checked(&h, &item), Some(false), "{params}: no check mark while Shift+Tab hides the panels"); + h.app.run("window.panel", params.clone()).unwrap(); + assert!(h.app.ui.dock && visible(&h.app.ui, "layers"), "{params}: Layers shows"); + assert_eq!(floating_window(&h.layout(), "layers").map(|w| w.rect), rect, "{params}: where it was"); + } +} + +/// While the dock is hidden, Window › Layers opens a flyout that was open in the icon column +/// instead of putting it away; a closed panel comes back and a background tab comes to the front. +/// With the dock showing, choosing the checked item again hides only that panel. +#[test] +fn window_panel_hidden_iconic_and_closed_cases() { + let mut h = Harness::new(); + let item = window_layers(); + h.app.run("window.collapseDock", json!({"collapsed": true})).unwrap(); + h.app.run("window.panel", item.clone()).unwrap(); + assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers")); + assert_eq!(checked(&h, &item), Some(true)); + h.app.ui.dock = false; + assert_eq!(checked(&h, &item), Some(false), "no check mark with the dock hidden"); + h.app.run("window.panel", item.clone()).unwrap(); + assert!(h.app.ui.dock, "the dock shows"); + assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers"), "the flyout stays open"); + assert_eq!(checked(&h, &item), Some(true)); + h.app.run("window.panel", item.clone()).unwrap(); + assert_eq!(h.app.ui.open_panel, None, "a visible flyout puts away"); + + let mut h = Harness::new(); + let libraries = json!({"panel": "libraries", "toggle": true}); + h.app.run("window.panel.close", json!({"panel": "libraries"})).unwrap(); + h.app.run("window.panel.activate", json!({"panel": "properties"})).unwrap(); + h.app.run("window.dock", json!({})).unwrap(); + assert_eq!((checked(&h, &libraries), checked(&h, &item)), (Some(false), Some(false))); + h.app.run("window.panel", libraries.clone()).unwrap(); + assert!(h.app.ui.dock && visible(&h.app.ui, "libraries"), "a closed panel comes back"); + h.app.run("window.panel", item.clone()).unwrap(); + assert!(visible(&h.app.ui, "layers") && !visible(&h.app.ui, "properties"), "a background tab comes to the front"); + assert_eq!((checked(&h, &libraries), checked(&h, &item)), (Some(false), Some(true)), "the tab in front is checked"); +} + +/// F7 with the panels hidden by Tab shows the dock with the Layers group instead of hiding the +/// group; the next F7 hides it. +#[test] +fn function_key_from_tab_hidden_dock_reveals_named_group() { + let mut h = Harness::new(); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(visible(&h.app.ui, "layers")); + let before = h.layout(); + h.app.run("window.dock", json!({})).unwrap(); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(h.app.ui.dock && visible(&h.app.ui, "layers"), "F7 shows the hidden group"); + assert_eq!(h.layout(), before); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(["properties", "layers", "libraries"].iter().all(|panel| !h.layout().contains(&panel.to_string())), "the next press hides the group"); +} + +/// Presentation Mode hides every panel: no panel is checked, `window.panel` fails and changes +/// nothing, and F7 is ignored. The other full screen modes show the panels as Normal does. +#[test] +fn presentation_panel_command_reports_unavailable_without_mutating_layout() { + let mut h = Harness::new(); + let item = window_layers(); + h.app.run("window.panel", json!({"panel": "layers"})).unwrap(); + h.app.run("window.panel.close", json!({"panel": "swatches"})).unwrap(); + h.app.run("view.presentation", json!({})).unwrap(); + assert_eq!(h.app.ui.screen_mode, 3); + assert_eq!(checked(&h, &item), Some(false), "no check mark in Presentation Mode"); + let before = (h.layout(), h.app.ui.dock, h.app.ui.open_panel.clone(), h.app.ui.docking_hidden.len()); + for params in [item.clone(), json!({"panel": "layers"}), json!({"panel": "swatches"}), json!({"panel": "brushes"})] { + let error = h.app.run("window.panel", params.clone()).unwrap_err(); + assert_eq!(error, "panels can't be shown in Presentation Mode", "{params}"); + assert_eq!((h.layout(), h.app.ui.dock, h.app.ui.open_panel.clone(), h.app.ui.docking_hidden.len()), before, "{params} changed nothing"); + } + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert_eq!(h.app.ui.screen_mode, 3); + assert_eq!(h.layout(), before.0, "F7 is ignored"); + for mode in [1, 2] { + h.app.run("view.screenMode", json!({"mode": mode})).unwrap(); + assert_eq!(checked(&h, &item), Some(true), "mode {mode}"); + h.app.run("window.panel", item.clone()).unwrap(); + assert!(!visible(&h.app.ui, "layers"), "mode {mode}: the checked item hides the panel"); + h.app.run("window.panel", item.clone()).unwrap(); + assert!(visible(&h.app.ui, "layers"), "mode {mode}: and shows it again"); + } +} + #[test] fn other_function_keys_and_keyboard_guards_keep_their_routes() { let mut h = Harness::new(); diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 2f834a272c..adf6cacf3b 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -450,10 +450,10 @@ impl Field { /// Type tool edits, the panel shows, nothing takes the focus and the command fails. → `{field}`. pub(crate) fn highlight(app: &mut VectorcraftApp, field: Field) -> Result { // `window.panel` would hide a floating panel in front of its window, or an open flyout. - if crate::panel_docking::showing(&app.ui, "character") { + if crate::panel_docking::visible(&app.ui, "character") { app.run("window.panel.activate", json!({"panel": "character"}))?; } else { - app.run("window.panel", json!({"panel": "character"}))?; + crate::panel_docking::reveal_panel(app, "character")?; } if text_style(app).is_none() { app.character_focus = None; diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 6a90cdfac0..1b5c381fe2 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -416,8 +416,7 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result Ok(json!({ "open": tool })), - "gradient" | "magicWand" => app.run("window.panel", json!({ "panel": tool })), + "gradient" | "magicWand" => crate::panel_docking::reveal_panel(app, tool), "artboard" => crate::dialogs::artboard_options::open(app), "eyedropper" => { crate::dialogs::eyedropper::open(app); diff --git a/crates/ui-egui/src/toolbar/tests.rs b/crates/ui-egui/src/toolbar/tests.rs index e8e5d8f12c..102979182c 100644 --- a/crates/ui-egui/src/toolbar/tests.rs +++ b/crates/ui-egui/src/toolbar/tests.rs @@ -155,6 +155,15 @@ fn more_tools_open_their_options() { assert!(crate::panel_docking::showing(&app.ui, "magicWand")); open_options(&mut app, "magicWand").unwrap(); assert!(crate::panel_docking::showing(&app.ui, "magicWand")); + // With the panels hidden by Tab, the double-click shows them with the panel. + app.run("window.dock", json!({})).unwrap(); + open_options(&mut app, "magicWand").unwrap(); + assert!(app.ui.dock && crate::panel_docking::visible(&app.ui, "magicWand"), "the Magic Wand panel shows"); + // In Presentation Mode the double-click does not fail, and the panel shows when the mode ends. + app.ui.screen_mode = 3; + open_options(&mut app, "magicWand").unwrap(); + app.ui.screen_mode = 0; + assert!(crate::panel_docking::visible(&app.ui, "magicWand"), "the Magic Wand panel stays in place"); } /// A double-click at `at`, `time` seconds in (a second apart from the last). diff --git a/docs/mcp.md b/docs/mcp.md index 4ac7a10930..be1119ee69 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1381,6 +1381,11 @@ key, the Batch dialog or `ui.action.play`), a key a command no longer reads, rec and the action still plays. `Session::replay` plays a journal the same way. The command still refuses an effect `color` or `channel` it can't read. +`window.panel` refuses every request in Presentation Mode, which hides every panel, and changes nothing. With +`toggle` it hides only a panel that can be seen, which is the panel the Window menu checks. While Tab or Shift+Tab +hides the dock, no panel is checked, and the panel shows again with the dock in its earlier place; the toolbar and the +Control bar stay as they are. + The export commands limit those keys. `document.export`, `document.exportSelection`, `document.serialize`, `app.export` and `file.export` refuse a key that no format takes, and ignore a key that only another format takes (`{"format": "pdf", "quality": 80}` writes the PDF). `document.exportPdf`, `document.pdfSettings` and `pdf.preset.save` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3cc8df2a9e..411e07e889 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -890,6 +890,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields, one press each — a numeric field's scrub label is not a tab stop of its own, and the Transform panel's, Properties panel's and Control bar's W/H link comes after the fields it sits between (`one_tab_moves_from_a_field_to_the_next`). A field's up/down spinner, the chevron that opens its presets (Stroke Weight, font size, Rotate, Export for Screens' scale) and the Transparency row's opacity slider chevron are not tab stops either. Alt+↓ with no other modifier in a field with presets opens them with the keyboard focus on the chosen one, instead of stepping the field down; Enter picks the focused preset without pressing a dialog's OK, and after a pick or Escape the field has the keyboard focus again (`tab_goes_from_field_to_field_past_the_spinner_and_the_preset_chevron`, `tab_goes_from_the_opacity_field_past_its_slider_chevron`, `alt_down_opens_a_fields_presets_for_the_keyboard`, `escape_closes_a_fields_presets_and_returns_to_the_field`). - Shift+Tab (`window.panels`) shows and hides the dock and the floating panels and leaves the toolbar and the Control bar as they are; hiding the panels with Tab or Shift+Tab also closes a panel popped out of the icon column. - The second Tab (`window.dock`) shows again only the bars the first Tab hid, after a restart too: a Control bar that was off stays off, a toolbar hidden with Window › Tools stays hidden, and panels shown with Shift+Tab in between stay shown. + - While Tab or Shift+Tab hides the panels, the Window menu checks no panel, and choosing a panel there, or pressing its function key, shows the dock with that panel in its earlier place instead of closing it; the toolbar and the Control bar stay as they are. In Presentation Mode no panel is checked, and `window.panel` fails and changes nothing. A tool double-click that opens the Gradient or Magic Wand panel and a Character field shortcut also show the hidden dock (`window_panel_from_tab_hidden_dock_reveals_named_front_tab`, `window_panel_from_shift_tab_hidden_floating_window_reveals_it`, `function_key_from_tab_hidden_dock_reveals_named_group`, `presentation_panel_command_reports_unavailable_without_mutating_layout`). - The function key shortcuts for Brushes, Color, Layers, Info, Gradient, Stroke, Attributes, Graphic Styles, Appearance, Align, Transform, Pathfinder, Transparency, and Symbols activate a background tab on the first press, hide its expanded dock or floating group when that tab is in front, and restore the group with all its tabs together on the next press; in an icon column they open and close the flyout while keeping the icons. A reassigned chord keeps that behavior, and Window > a hidden sibling restores the group. - On Windows and Linux, F2, F3 and F4 run Cut, Copy and Paste alongside Ctrl+X, Ctrl+C and Ctrl+V, and in the web build they do so everywhere except macOS. The Edit menu keeps the Ctrl shortcuts, and Keyboard Shortcuts lists both keys of each row. On macOS only Cmd+X, Cmd+C and Cmd+V are the defaults. F2, F3 and F4 do not run these commands in a focused text field or while the Type tool edits text. A saved shortcut set that assigns one of these keys to another command keeps that assignment. Public reference: [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html). - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected). From 1db5b2ce531479c9b10089d6ac24d923f92e9019 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:32:03 -0800 Subject: [PATCH 17/92] i18n: plug-in host errors are translated in every complete catalog The plug-in host's manifest, module and run errors, the errors about an importer's, exporter's or effect's output and the approval error had no @msg row in most complete-message catalogs, and the reasons its error helpers put inside them (bad(), wrong(), out_err(), in_range(), unit(), read_packed()) were never scanned, so the status bar showed them in English or mixed two languages. - Every complete-message catalog gets the rows in a "Plug-in host messages" section, and the Hindi rows for these messages are fixed. - The message scan reads the plug-in host's error helpers, and objects.rs writes its two duplicate-id errors as whole strings. - message() leaves a captured JSON object or array as written. - Templates that start with text match before templates that start with a placeholder, so a wrapping message uses its own row. --- crates/plugins/src/objects.rs | 7 +- crates/ui-egui/src/i18n.rs | 6 +- crates/ui-egui/src/i18n/ca.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/catalog.rs | 6 +- crates/ui-egui/src/i18n/cs.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/de.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/el.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/es.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/fi.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/fr.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/hi.tsv | 135 +++++++++++++++----- crates/ui-egui/src/i18n/hu.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/it.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/ja.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/ka.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/ko.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/pl.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/pt-br.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/ru.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/sv.tsv | 68 ++++++++++ crates/ui-egui/src/i18n/tests.rs | 186 ++++++++++++++++++++++++++-- crates/ui-egui/src/i18n/tr.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/uk.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/vi.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/zh-hans.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/zh-hant.tsv | 104 ++++++++++++++++ docs/target-app-parity.md | 1 + 27 files changed, 2439 insertions(+), 50 deletions(-) diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index 0e149579f5..d2a3c94ccc 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -1243,8 +1243,11 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec, match o.id { Some(id) => { let mut n = originals.remove(&id).ok_or_else(|| { - 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)) + if wanted.contains(&id) { + out_err(&at, format!("{} is listed twice", id.0)) + } else { + out_err(&at, format!("{} is not the id of an input object", id.0)) + } })?; // Type, images and symbol instances are only read: writing one would make it a path. if input_kind(&n).is_some() && !o.kept { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 251dc585e2..7ee09ee14e 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -658,7 +658,8 @@ pub fn label_or_name(lang: Lang, s: &str, built_in: bool) -> &str { const MESSAGE_DEPTH: usize = 5; /// Translate a status or error message for display (see the module docs). Unknown messages, and -/// parts of them (file names, numbers, a reason with no entry), stay as they are. +/// parts of them (file names, numbers, a reason with no entry, a JSON object or array such as +/// `{"objects": [...]}`), stay as they are. pub fn message(lang: Lang, s: &str) -> String { message_at(lang, s, MESSAGE_DEPTH) } @@ -668,8 +669,9 @@ fn message_at(lang: Lang, s: &str, depth: usize) -> String { let count = caps.iter().find(|(k, _)| *k == "n").and_then(|(_, v)| v.parse::().ok()); let form = count.map_or(forms.len().saturating_sub(1), |n| (lang.0.plural)(n).min(forms.len().saturating_sub(1))); let Some(template) = forms.get(form) else { return s.to_string() }; + let nested = |v: &str| depth > 1 && !v.starts_with(['{', '[']); let values: Vec<(&str, String)> = - caps.into_iter().map(|(k, v)| (k, if depth > 1 { message_at(lang, v, depth - 1) } else { v.to_string() })).collect(); + caps.into_iter().map(|(k, v)| (k, if nested(v) { message_at(lang, v, depth - 1) } else { v.to_string() })).collect(); let args: Vec<(&str, &str)> = values.iter().map(|(k, v)| (*k, v.as_str())).collect(); fill_once(template, &args) } diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index cd6dfad587..27430527ec 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6152,6 +6152,110 @@ digits Malayalam Malaiàlam @msg {status} ({e}) {status} ({e}) @msg {tool} isn't in a toolbar group of several tools {tool} no forma part d’un grup de diverses eines a la barra d’eines @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants «{_1}» demana un pressupost elevat (cada execució fins a {minutes} minuts i {times} vegades les instruccions habituals): una persona l’ha d’aprovar a l’aplicació d’escriptori, o passa `allowHeavy: true` en una sessió iniciada amb --allow-plugin-grants +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` ha de tenir 1-64 caràcters imprimibles +@msg `version`, `description` or `author` is too long `version`, `description` o `author` és massa llarg +@msg `warnings` must be a list of strings `warnings` ha de ser una llista de cadenes +@msg `{k}` must be one of {_1} `{k}` ha de ser un de {_1} +@msg `{k}` must be {what} `{k}` ha de ser {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` un exportador declara d’1 a {MAX_EXTENSIONS} `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` com a màxim {MAX_UNGROUPED} paràmetres fora de grups: doneu un `group` als altres +@msg can't encode the input no es pot codificar l’entrada +@msg expected {"dataBase64"} s’esperava {"dataBase64"} +@msg expected {"objects"} s’esperava {"objects"} +@msg export `{name}`: {e} exportació del mòdul `{name}`: {e} +@msg extension {e} is listed twice l’extensió {e} apareix dues vegades +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot extensió {e}: feu servir 1-16 lletres minúscules i dígits, sense el punt +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - ID no vàlid {_1}: feu servir 1-64 caràcters d’A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} nom de paràmetre no vàlid {k} +@msg manifest is not UTF-8 el manifest no és UTF-8 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) el manifest és massa llarg (límit {MAX_MANIFEST_BYTES} bytes) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) el manifest té {_1} bytes (límit {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) el mòdul té {_1} bytes (límit {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own cal aprovació: demana {0}, que l’usuari concedeix en instal·lar-lo per separat +@msg not JSON: {e} no és JSON: {e} +@msg not a WebAssembly binary (missing \0asm header) no és un binari WebAssembly (falta la capçalera \0asm) +@msg parameter `{k}`: {what} paràmetre `{k}`: {what} +@msg parameter `{name}`: {what} paràmetre `{name}`: {what} +@msg plug-in parameters must be a JSON object els paràmetres del complement han de ser un objecte JSON +@msg the module must export its linear memory as `memory` el mòdul ha d’exportar la seva memòria lineal com a `memory` +@msg the objects to change are no longer in the document els objectes que calia canviar ja no són al document +@msg the runtime panicked l’entorn d’execució ha fallat +@msg unexpected host trap aturada inesperada (trap) de l’amfitrió +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version ha retornat {v}; aquest amfitrió implementa la versió {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc ha retornat 0 (sense memòria) +@msg vc_alloc returned a block outside memory vc_alloc ha retornat un bloc fora de la memòria +@msg vc_run returned error code {packed} vc_run ha retornat el codi d’error {packed} +@msg {_1} is not a file {_1} no és un fitxer +@msg {_1} is {_2} bytes (limit {_3}) {_1} té {_2} bytes (límit {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} objectes (límit {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} apunta fora de la memòria +@msg `angle` must be a number `angle` ha de ser un nombre +@msg `aspect` must be from 0.01 to 100 `aspect` ha d’estar entre 0.01 i 100 +@msg `cap` must be "butt", "round" or "square" `cap` ha de ser "butt", "round" o "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` ha de ser "nonzero" o "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` ha de ser una adreça https:// de {MAX_HELP_URL} bytes com a màxim +@msg `help` must be 1-{MAX_HELP} printable characters `help` ha de tenir 1-{MAX_HELP} caràcters imprimibles +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` ha de ser un id d’efecte d’1-{MAX_EFFECT_ID} bytes +@msg `id` must be the id of an input object `id` ha de ser l’id d’un objecte d’entrada +@msg `join` must be "miter", "round" or "bevel" `join` ha de ser "miter", "round" o "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` i `group` han de tenir 1-{MAX_LABEL} caràcters imprimibles +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` ha de ser 1-{MAX_TEXT}, i el valor predeterminat no pot ser més llarg +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` i `maxItems` han de ser 0-{MAX_LIST}, `maxItems` com a mínim 1, i el valor predeterminat ha de tenir aquesta quantitat +@msg `name` must be a string `name` ha de ser una cadena +@msg `opacity` must be a number `opacity` ha de ser un nombre +@msg `type` must be "path" or "compound" `type` ha de ser "path" o "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` ha de tenir 1-{MAX_UNIT} caràcters imprimibles +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` ha d’indicar d’1 a {MAX_CONDITION_VALUES} valors per a `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` anomena `{other}`, que no és un paràmetre declarat abans d’aquest +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` anomena com a màxim {MAX_CONDITIONS} paràmetres +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` pren els valors de `{other}`, d’una casella de selecció o d’una opció +@msg `visible` must be true or false `visible` ha de ser true o false +@msg `{key}` must be a number from {_1} to {_2} `{key}` ha de ser un nombre de {_1} a {_2} +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` ha de ser una matriu de {MAX_ITEMS} entrades com a màxim +@msg a color default must be a color object or a hex string such as "#3a7bd5" un color predeterminat ha de ser un objecte de color o una cadena hexadecimal com ara "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" un objecte de color o una cadena hexadecimal com ara "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines un degradat de forma lliure conté com a màxim {MAX_ITEMS} punts i línies +@msg a freeform gradient line names a point it doesn't have una línia del degradat de forma lliure anomena un punt que el degradat no té +@msg a gradient needs 1-{MAX_ITEMS} stops un degradat necessita 1-{MAX_ITEMS} parades +@msg a list of numbers una llista de nombres +@msg a list of {min_items} to {max_items} numbers una llista de {min_items} a {max_items} nombres +@msg a new object needs a `path` un objecte nou necessita un `path` +@msg a number un nombre +@msg a painted live effect returns only `objects` un efecte dinàmic de pintura només retorna `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` una peça d’un efecte dinàmic de pintura només té `path`, `fillRule`, `fills` i `strokes` +@msg a read-only object was left out s’ha omès un objecte de només lectura +@msg a read-only object was left out: return it, as {"id"} alone or as it was given s’ha omès un objecte de només lectura: retorneu-lo com a {"id"} sol o tal com s’ha rebut +@msg a string una cadena +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` un traç només té `paint`, `width`, `join`, `cap` i `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} un traç ha de ser {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} un efecte ha de ser {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` un efecte en un farciment només retorna `fills` +@msg an effect on a stroke returns only `strokes` un efecte en un traç només retorna `strokes` +@msg an object must be a JSON object un objecte ha de ser un objecte JSON +@msg bad range interval no vàlid +@msg choices must be 1-64 non-empty, printable strings les opcions han de ser 1-64 cadenes imprimibles no buides +@msg coordinate out of range (limit ±{MAX_COORD} pt) coordenada fora de l’interval (límit ±{MAX_COORD} pt) +@msg default is not one of the options el valor predeterminat no és cap de les opcions +@msg each piece needs a `path` cada peça necessita un `path` +@msg is listed twice apareix dues vegades +@msg pattern transform out of range transformació del motiu fora de l’interval +@msg read-only field '{field}' changed ha canviat el camp de només lectura '{field}' +@msg stops must be in order of `offset` les parades han d’estar en ordre d’`offset` +@msg the document has no pattern {pattern} el document no té el motiu {pattern} +@msg the document has no swatch {n} el document no té la mostra {n} +@msg the output la sortida +@msg the path has no anchor points el traçat no té punts d’ancoratge +@msg too many anchor points (limit {MAX_ANCHORS}) massa punts d’ancoratge (límit {MAX_ANCHORS}) +@msg true or false true o false +@msg unknown key `{k}`; the keys here are {_1} clau desconeguda `{k}`; les claus aquí són {_1} +@msg {_1} is listed twice {_1} apareix dues vegades +@msg {_1} is not the id of an input object {_1} no és l’id d’un objecte d’entrada +@msg {_1} is read-only {_1} és de només lectura +@msg {what} must be from {lo} to {hi} {what} ha d’estar entre {lo} i {hi} +@msg a color component un component de color +@msg a plate una planxa # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Instal·la «{name}», que demana aquests permisos? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/catalog.rs b/crates/ui-egui/src/i18n/catalog.rs index 8999157286..51259cb3d4 100644 --- a/crates/ui-egui/src/i18n/catalog.rs +++ b/crates/ui-egui/src/i18n/catalog.rs @@ -188,8 +188,10 @@ impl Catalog { } } } - // Most literal text first, so `Couldn't open {name}: {e}` wins over `{path}: {e}`. - c.templates.sort_by_key(|t| std::cmp::Reverse(t.literal)); + // Templates that start with text before those that start with a placeholder, so + // `plug-in manifest: {0}` wins over `{_1} is {_2} bytes (limit {_3})` for a message it + // wraps; then most literal text first, so `Couldn't open {name}: {e}` wins over `{path}: {e}`. + c.templates.sort_by_key(|t| (matches!(t.pieces.first(), Some(Piece::Hole(_))), std::cmp::Reverse(t.literal))); c } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 00de3e7057..284035ea45 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6094,6 +6094,110 @@ axis Y Axis osa Y @msg a live effect runs while the document is drawn, so it can't be `heavy` živý efekt běží během vykreslování dokumentu, takže nemůže být `heavy` @msg an exporter always gets an export's budget: leave `budget` out exportní zásuvný modul dostává vždy rozpočet exportu: klíč `budget` vynechte @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants „{_1}“ žádá o velký výpočetní rozpočet (každé spuštění až {minutes} minut a {times}krát víc instrukcí než obvykle): musí ho schválit člověk v desktopové aplikaci, nebo předejte `allowHeavy: true` v relaci spuštěné s --allow-plugin-grants +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` musí mít 1-64 tisknutelných znaků +@msg `version`, `description` or `author` is too long `version`, `description` nebo `author` je příliš dlouhé +@msg `warnings` must be a list of strings `warnings` musí být seznam řetězců +@msg `{k}` must be one of {_1} `{k}` musí být jedno z {_1} +@msg `{k}` must be {what} `{k}` musí být {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` exportní zásuvný modul deklaruje 1 až {MAX_EXTENSIONS} `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` mimo skupiny smí být nejvýše {MAX_UNGROUPED} parametrů: ostatním přiřaďte `group` +@msg can't encode the input vstup nelze zakódovat +@msg expected {"dataBase64"} očekáváno {"dataBase64"} +@msg expected {"objects"} očekáváno {"objects"} +@msg export `{name}`: {e} export `{name}`: {e} +@msg extension {e} is listed twice přípona {e} je uvedena dvakrát +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot přípona {e}: použijte 1-16 malých písmen a číslic, bez tečky +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - neplatné ID {_1}: použijte 1-64 znaků z A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} neplatný název parametru {k} +@msg manifest is not UTF-8 manifest není v kódování UTF-8 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) manifest je příliš dlouhý (limit {MAX_MANIFEST_BYTES} bajtů) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) manifest má {_1} bajtů (limit {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) modul má {_1} bajtů (limit {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own vyžaduje schválení: žádá o {0}, což uživatel povolí, když ho instaluje samostatně +@msg not JSON: {e} není JSON: {e} +@msg not a WebAssembly binary (missing \0asm header) není binární soubor WebAssembly (chybí hlavička \0asm) +@msg parameter `{k}`: {what} parametr `{k}`: {what} +@msg parameter `{name}`: {what} parametr `{name}`: {what} +@msg plug-in parameters must be a JSON object parametry zásuvného modulu musí být objekt JSON +@msg the module must export its linear memory as `memory` modul musí exportovat svou lineární paměť jako `memory` +@msg the objects to change are no longer in the document objekty ke změně už v dokumentu nejsou +@msg the runtime panicked běhové prostředí havarovalo +@msg unexpected host trap neočekávaná chyba (trap) hostitele +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version vrátila {v}; tento hostitel implementuje verzi {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc vrátila 0 (nedostatek paměti) +@msg vc_alloc returned a block outside memory vc_alloc vrátila blok mimo paměť +@msg vc_run returned error code {packed} vc_run vrátila chybový kód {packed} +@msg {_1} is not a file {_1} není soubor +@msg {_1} is {_2} bytes (limit {_3}) {_1} má {_2} bajtů (limit {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} objektů (limit {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} ukazuje mimo paměť +@msg `angle` must be a number `angle` musí být číslo +@msg `aspect` must be from 0.01 to 100 `aspect` musí být od 0.01 do 100 +@msg `cap` must be "butt", "round" or "square" `cap` musí být "butt", "round" nebo "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` musí být "nonzero" nebo "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` musí být adresa https:// o nejvýše {MAX_HELP_URL} bajtech +@msg `help` must be 1-{MAX_HELP} printable characters `help` musí mít 1-{MAX_HELP} tisknutelných znaků +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` musí být ID efektu o 1-{MAX_EFFECT_ID} bajtech +@msg `id` must be the id of an input object `id` musí být ID vstupního objektu +@msg `join` must be "miter", "round" or "bevel" `join` musí být "miter", "round" nebo "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` a `group` musí mít 1-{MAX_LABEL} tisknutelných znaků +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` musí být 1-{MAX_TEXT} a výchozí hodnota nesmí být delší +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` a `maxItems` musí být 0-{MAX_LIST}, `maxItems` alespoň 1 a výchozí hodnota musí mít tolik položek +@msg `name` must be a string `name` musí být řetězec +@msg `opacity` must be a number `opacity` musí být číslo +@msg `type` must be "path" or "compound" `type` musí být "path" nebo "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` musí mít 1-{MAX_UNIT} tisknutelných znaků +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` musí uvádět 1 až {MAX_CONDITION_VALUES} hodnot pro `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` uvádí `{other}`, což není parametr deklarovaný před tímto +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` smí uvádět nejvýše {MAX_CONDITIONS} parametrů +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` přebírá hodnoty `{other}`, a to zaškrtávacího políčka nebo volby +@msg `visible` must be true or false `visible` musí být true nebo false +@msg `{key}` must be a number from {_1} to {_2} `{key}` musí být číslo od {_1} do {_2} +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` musí být pole s nejvýše {MAX_ITEMS} položkami +@msg a color default must be a color object or a hex string such as "#3a7bd5" výchozí barva musí být objekt barvy nebo šestnáctkový řetězec, například "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" objekt barvy nebo šestnáctkový řetězec, například "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines volný přechod obsahuje nejvýše {MAX_ITEMS} bodů a čar +@msg a freeform gradient line names a point it doesn't have čára volného přechodu uvádí bod, který přechod nemá +@msg a gradient needs 1-{MAX_ITEMS} stops přechod potřebuje 1-{MAX_ITEMS} zarážek +@msg a list of numbers seznam čísel +@msg a list of {min_items} to {max_items} numbers seznam {min_items} až {max_items} čísel +@msg a new object needs a `path` nový objekt potřebuje `path` +@msg a number číslo +@msg a painted live effect returns only `objects` malující živý efekt vrací jen `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` část malujícího živého efektu má jen `path`, `fillRule`, `fills` a `strokes` +@msg a read-only object was left out objekt jen pro čtení byl vynechán +@msg a read-only object was left out: return it, as {"id"} alone or as it was given objekt jen pro čtení byl vynechán: vraťte ho jako samotné {"id"} nebo tak, jak byl předán +@msg a string řetězec +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` tah má jen `paint`, `width`, `join`, `cap` a `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} tah musí být {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} efekt musí být {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` efekt na výplni vrací jen `fills` +@msg an effect on a stroke returns only `strokes` efekt na tahu vrací jen `strokes` +@msg an object must be a JSON object objekt musí být objekt JSON +@msg bad range neplatný rozsah +@msg choices must be 1-64 non-empty, printable strings volby musí být 1-64 neprázdných tisknutelných řetězců +@msg coordinate out of range (limit ±{MAX_COORD} pt) souřadnice mimo rozsah (limit ±{MAX_COORD} pt) +@msg default is not one of the options výchozí hodnota není jednou z možností +@msg each piece needs a `path` každá část potřebuje `path` +@msg is listed twice je uveden dvakrát +@msg pattern transform out of range transformace vzorku mimo rozsah +@msg read-only field '{field}' changed pole jen pro čtení '{field}' bylo změněno +@msg stops must be in order of `offset` zarážky musí být seřazeny podle `offset` +@msg the document has no pattern {pattern} dokument nemá vzorek {pattern} +@msg the document has no swatch {n} dokument nemá vzorek barvy {n} +@msg the output výstup +@msg the path has no anchor points cesta nemá žádné kotevní body +@msg too many anchor points (limit {MAX_ANCHORS}) příliš mnoho kotevních bodů (limit {MAX_ANCHORS}) +@msg true or false true nebo false +@msg unknown key `{k}`; the keys here are {_1} neznámý klíč `{k}`; zde platné klíče jsou {_1} +@msg {_1} is listed twice {_1} je uveden dvakrát +@msg {_1} is not the id of an input object {_1} není ID vstupního objektu +@msg {_1} is read-only {_1} je jen pro čtení +@msg {what} must be from {lo} to {hi} {what} musí být od {lo} do {hi} +@msg a color component složka barvy +@msg a plate deska # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Nainstalovat „{name}“, který žádá o tato oprávnění? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0de0dce0b9..38e0c9cfc5 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5685,6 +5685,110 @@ axis Y Axis Y-Achse @msg a live effect runs while the document is drawn, so it can't be `heavy` ein Live-Effekt läuft, während das Dokument gezeichnet wird, daher kann er nicht `heavy` sein @msg an exporter always gets an export's budget: leave `budget` out ein Export-Plug-in erhält immer das Budget eines Exports: Lassen Sie `budget` weg @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants „{_1}“ verlangt ein großes Rechenbudget (jede Ausführung bis zu {minutes} Minuten und {times}-mal so viele Anweisungen wie üblich): Eine Person muss es in der Desktop-App genehmigen, oder übergeben Sie `allowHeavy: true` in einer mit --allow-plugin-grants gestarteten Sitzung +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` muss 1-64 druckbare Zeichen haben +@msg `version`, `description` or `author` is too long `version`, `description` oder `author` ist zu lang +@msg `warnings` must be a list of strings `warnings` muss eine Liste von Zeichenketten sein +@msg `{k}` must be one of {_1} `{k}` muss einer der folgenden Werte sein: {_1} +@msg `{k}` must be {what} `{k}` muss {what} sein +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` ein Export-Plug-in gibt 1 bis {MAX_EXTENSIONS} `extensions` an +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` höchstens {MAX_UNGROUPED} Parameter außerhalb von Gruppen: Geben Sie den anderen eine `group` +@msg can't encode the input die Eingabe kann nicht codiert werden +@msg expected {"dataBase64"} erwartet: {"dataBase64"} +@msg expected {"objects"} erwartet: {"objects"} +@msg export `{name}`: {e} Modulexport `{name}`: {e} +@msg extension {e} is listed twice die Dateiendung {e} ist doppelt aufgeführt +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot Dateiendung {e}: Verwenden Sie 1-16 Kleinbuchstaben und Ziffern, ohne Punkt +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - ungültige ID {_1}: Verwenden Sie 1-64 Zeichen aus A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} ungültiger Parametername {k} +@msg manifest is not UTF-8 das Manifest ist nicht in UTF-8 codiert +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) das Manifest ist zu lang (Grenze {MAX_MANIFEST_BYTES} Byte) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) das Manifest ist {_1} Byte groß (Grenze {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) das Modul ist {_1} Byte groß (Grenze {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own muss genehmigt werden: Es verlangt {0}, was der Benutzer gewährt, wenn er es einzeln installiert +@msg not JSON: {e} kein JSON: {e} +@msg not a WebAssembly binary (missing \0asm header) keine WebAssembly-Binärdatei (der Header \0asm fehlt) +@msg parameter `{k}`: {what} Parameter `{k}`: {what} +@msg parameter `{name}`: {what} Parameter `{name}`: {what} +@msg plug-in parameters must be a JSON object die Plug-in-Parameter müssen ein JSON-Objekt sein +@msg the module must export its linear memory as `memory` das Modul muss seinen linearen Speicher als `memory` exportieren +@msg the objects to change are no longer in the document die zu ändernden Objekte sind nicht mehr im Dokument +@msg the runtime panicked die Laufzeitumgebung ist abgestürzt +@msg unexpected host trap unerwarteter Abbruch (Trap) im Host +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version hat {v} zurückgegeben; dieser Host unterstützt Version {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc hat 0 zurückgegeben (kein Speicher mehr) +@msg vc_alloc returned a block outside memory vc_alloc hat einen Block außerhalb des Speichers zurückgegeben +@msg vc_run returned error code {packed} vc_run hat den Fehlercode {packed} zurückgegeben +@msg {_1} is not a file {_1} ist keine Datei +@msg {_1} is {_2} bytes (limit {_3}) {_1} ist {_2} Byte groß (Grenze {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} Objekte (Grenze {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} zeigt außerhalb des Speichers +@msg `angle` must be a number `angle` muss eine Zahl sein +@msg `aspect` must be from 0.01 to 100 `aspect` muss zwischen 0.01 und 100 liegen +@msg `cap` must be "butt", "round" or "square" `cap` muss "butt", "round" oder "square" sein +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` muss "nonzero" oder "evenodd" sein +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` muss eine https://-Adresse mit höchstens {MAX_HELP_URL} Byte sein +@msg `help` must be 1-{MAX_HELP} printable characters `help` muss 1-{MAX_HELP} druckbare Zeichen haben +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` muss eine Effekt-ID mit 1-{MAX_EFFECT_ID} Byte sein +@msg `id` must be the id of an input object `id` muss die ID eines Eingabeobjekts sein +@msg `join` must be "miter", "round" or "bevel" `join` muss "miter", "round" oder "bevel" sein +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` und `group` müssen 1-{MAX_LABEL} druckbare Zeichen haben +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` muss 1-{MAX_TEXT} sein, und der Standardwert darf nicht länger sein +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` und `maxItems` müssen 0-{MAX_LIST} sein, `maxItems` mindestens 1, und der Standardwert muss so viele Einträge haben +@msg `name` must be a string `name` muss eine Zeichenkette sein +@msg `opacity` must be a number `opacity` muss eine Zahl sein +@msg `type` must be "path" or "compound" `type` muss "path" oder "compound" sein +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` muss 1-{MAX_UNIT} druckbare Zeichen haben +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` muss 1 bis {MAX_CONDITION_VALUES} Werte für `{other}` nennen +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` nennt `{other}`, aber das ist kein Parameter, der vor diesem deklariert ist +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` darf höchstens {MAX_CONDITIONS} Parameter nennen +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` nimmt die Werte von `{other}` an, die eines Kontrollkästchens oder einer Auswahl +@msg `visible` must be true or false `visible` muss true oder false sein +@msg `{key}` must be a number from {_1} to {_2} `{key}` muss eine Zahl von {_1} bis {_2} sein +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` muss ein Array mit höchstens {MAX_ITEMS} Einträgen sein +@msg a color default must be a color object or a hex string such as "#3a7bd5" ein Farb-Standardwert muss ein Farbobjekt oder ein Hex-String wie "#3a7bd5" sein +@msg a color object or a hex string such as "#3a7bd5" ein Farbobjekt oder ein Hex-String wie "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines ein Freiform-Verlauf enthält höchstens {MAX_ITEMS} Punkte und Linien +@msg a freeform gradient line names a point it doesn't have eine Linie des Freiform-Verlaufs nennt einen Punkt, den der Verlauf nicht hat +@msg a gradient needs 1-{MAX_ITEMS} stops ein Verlauf braucht 1-{MAX_ITEMS} Farbstopps +@msg a list of numbers eine Liste von Zahlen +@msg a list of {min_items} to {max_items} numbers eine Liste von {min_items} bis {max_items} Zahlen +@msg a new object needs a `path` ein neues Objekt braucht einen `path` +@msg a number eine Zahl +@msg a painted live effect returns only `objects` ein malender Live-Effekt gibt nur `objects` zurück +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` ein Teil eines malenden Live-Effekts hat nur `path`, `fillRule`, `fills` und `strokes` +@msg a read-only object was left out ein schreibgeschütztes Objekt wurde weggelassen +@msg a read-only object was left out: return it, as {"id"} alone or as it was given ein schreibgeschütztes Objekt wurde weggelassen: Geben Sie es zurück, als {"id"} allein oder so, wie es übergeben wurde +@msg a string eine Zeichenkette +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` eine Kontur hat nur `paint`, `width`, `join`, `cap` und `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} eine Kontur muss {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} sein +@msg an effect must be {"id", "params"?, "visible"?} ein Effekt muss {"id", "params"?, "visible"?} sein +@msg an effect on a fill returns only `fills` ein Effekt auf einer Fläche gibt nur `fills` zurück +@msg an effect on a stroke returns only `strokes` ein Effekt auf einer Kontur gibt nur `strokes` zurück +@msg an object must be a JSON object ein Objekt muss ein JSON-Objekt sein +@msg bad range ungültiger Bereich +@msg choices must be 1-64 non-empty, printable strings die Auswahlwerte müssen 1-64 nicht leere, druckbare Zeichenketten sein +@msg coordinate out of range (limit ±{MAX_COORD} pt) Koordinate außerhalb des Bereichs (Grenze ±{MAX_COORD} pt) +@msg default is not one of the options der Standardwert ist keine der Optionen +@msg each piece needs a `path` jedes Teil braucht einen `path` +@msg is listed twice ist doppelt aufgeführt +@msg pattern transform out of range Mustertransformation außerhalb des Bereichs +@msg read-only field '{field}' changed das schreibgeschützte Feld '{field}' wurde geändert +@msg stops must be in order of `offset` die Farbstopps müssen nach `offset` geordnet sein +@msg the document has no pattern {pattern} das Dokument hat kein Muster {pattern} +@msg the document has no swatch {n} das Dokument hat kein Farbfeld {n} +@msg the output die Ausgabe +@msg the path has no anchor points der Pfad hat keine Ankerpunkte +@msg too many anchor points (limit {MAX_ANCHORS}) zu viele Ankerpunkte (Grenze {MAX_ANCHORS}) +@msg true or false true oder false +@msg unknown key `{k}`; the keys here are {_1} unbekannter Schlüssel `{k}`; die Schlüssel hier sind {_1} +@msg {_1} is listed twice {_1} ist doppelt aufgeführt +@msg {_1} is not the id of an input object {_1} ist nicht die ID eines Eingabeobjekts +@msg {_1} is read-only {_1} ist schreibgeschützt +@msg {what} must be from {lo} to {hi} {what} muss zwischen {lo} und {hi} liegen +@msg a color component eine Farbkomponente +@msg a plate eine Druckplatte # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? „{name}“ installieren, das diese Berechtigungen verlangt? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index a75edff7a8..7d9db021eb 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6045,6 +6045,110 @@ axis Y Axis Άξονας Y @msg a live effect runs while the document is drawn, so it can't be `heavy` ένα δυναμικό εφέ εκτελείται κατά τη σχεδίαση του εγγράφου, οπότε δεν μπορεί να είναι `heavy` @msg an exporter always gets an export's budget: leave `budget` out ένα πρόσθετο εξαγωγής παίρνει πάντα τον προϋπολογισμό μιας εξαγωγής: παραλείψτε το `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants το «{_1}» ζητά μεγάλο υπολογιστικό προϋπολογισμό (κάθε εκτέλεση έως {minutes} λεπτά και {times} φορές τις συνήθεις εντολές): πρέπει να το εγκρίνει ένα άτομο στην εφαρμογή επιφάνειας εργασίας ή περάστε `allowHeavy: true` σε μια συνεδρία που ξεκίνησε με --allow-plugin-grants +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters το `name` πρέπει να έχει 1-64 εκτυπώσιμους χαρακτήρες +@msg `version`, `description` or `author` is too long το `version`, το `description` ή το `author` είναι πολύ μεγάλο +@msg `warnings` must be a list of strings το `warnings` πρέπει να είναι λίστα συμβολοσειρών +@msg `{k}` must be one of {_1} το `{k}` πρέπει να είναι ένα από τα {_1} +@msg `{k}` must be {what} το `{k}` πρέπει να είναι {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` ένα πρόσθετο εξαγωγής δηλώνει 1 έως {MAX_EXTENSIONS} `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` το πολύ {MAX_UNGROUPED} παράμετροι εκτός ομάδων: δώστε στις υπόλοιπες ένα `group` +@msg can't encode the input η είσοδος δεν μπορεί να κωδικοποιηθεί +@msg expected {"dataBase64"} αναμενόταν {"dataBase64"} +@msg expected {"objects"} αναμενόταν {"objects"} +@msg export `{name}`: {e} εξαγωγή `{name}`: {e} +@msg extension {e} is listed twice η επέκταση {e} αναφέρεται δύο φορές +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot επέκταση {e}: χρησιμοποιήστε 1-16 πεζά γράμματα και ψηφία, χωρίς την τελεία +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - μη έγκυρο αναγνωριστικό {_1}: χρησιμοποιήστε 1-64 χαρακτήρες από A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} μη έγκυρο όνομα παραμέτρου {k} +@msg manifest is not UTF-8 το αρχείο δηλώσεων δεν είναι σε UTF-8 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) το αρχείο δηλώσεων είναι πολύ μεγάλο (όριο {MAX_MANIFEST_BYTES} byte) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) το αρχείο δηλώσεων έχει {_1} byte (όριο {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) η λειτουργική μονάδα έχει {_1} byte (όριο {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own χρειάζεται έγκριση: ζητά {0}, που ο χρήστης παραχωρεί όταν το εγκαθιστά μόνο του +@msg not JSON: {e} δεν είναι JSON: {e} +@msg not a WebAssembly binary (missing \0asm header) δεν είναι δυαδικό αρχείο WebAssembly (λείπει η κεφαλίδα \0asm) +@msg parameter `{k}`: {what} παράμετρος `{k}`: {what} +@msg parameter `{name}`: {what} παράμετρος `{name}`: {what} +@msg plug-in parameters must be a JSON object οι παράμετροι του προσθέτου πρέπει να είναι αντικείμενο JSON +@msg the module must export its linear memory as `memory` η λειτουργική μονάδα πρέπει να εξάγει τη γραμμική της μνήμη ως `memory` +@msg the objects to change are no longer in the document τα αντικείμενα προς αλλαγή δεν βρίσκονται πια στο έγγραφο +@msg the runtime panicked το περιβάλλον εκτέλεσης κατέρρευσε +@msg unexpected host trap μη αναμενόμενη παγίδευση του κεντρικού υπολογιστή +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} η vc_abi_version επέστρεψε {v}· αυτός ο κεντρικός υπολογιστής υλοποιεί την έκδοση {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) η vc_alloc επέστρεψε 0 (η μνήμη εξαντλήθηκε) +@msg vc_alloc returned a block outside memory η vc_alloc επέστρεψε ένα μπλοκ εκτός μνήμης +@msg vc_run returned error code {packed} η vc_run επέστρεψε τον κωδικό σφάλματος {packed} +@msg {_1} is not a file το {_1} δεν είναι αρχείο +@msg {_1} is {_2} bytes (limit {_3}) το {_1} έχει {_2} byte (όριο {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} αντικείμενα (όριο {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory το {what} δείχνει εκτός μνήμης +@msg `angle` must be a number το `angle` πρέπει να είναι αριθμός +@msg `aspect` must be from 0.01 to 100 το `aspect` πρέπει να είναι από 0.01 έως 100 +@msg `cap` must be "butt", "round" or "square" το `cap` πρέπει να είναι "butt", "round" ή "square" +@msg `fillRule` must be "nonzero" or "evenodd" το `fillRule` πρέπει να είναι "nonzero" ή "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes το `helpUrl` πρέπει να είναι διεύθυνση https:// έως {MAX_HELP_URL} byte +@msg `help` must be 1-{MAX_HELP} printable characters το `help` πρέπει να έχει 1-{MAX_HELP} εκτυπώσιμους χαρακτήρες +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes το `id` πρέπει να είναι αναγνωριστικό εφέ 1-{MAX_EFFECT_ID} byte +@msg `id` must be the id of an input object το `id` πρέπει να είναι το αναγνωριστικό ενός αντικειμένου εισόδου +@msg `join` must be "miter", "round" or "bevel" το `join` πρέπει να είναι "miter", "round" ή "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters τα `label` και `group` πρέπει να έχουν 1-{MAX_LABEL} εκτυπώσιμους χαρακτήρες +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer το `maxLength` πρέπει να είναι 1-{MAX_TEXT} και η προεπιλογή όχι μεγαλύτερη +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many τα `minItems` και `maxItems` πρέπει να είναι 0-{MAX_LIST}, το `maxItems` τουλάχιστον 1 και η προεπιλογή να έχει τόσα στοιχεία +@msg `name` must be a string το `name` πρέπει να είναι συμβολοσειρά +@msg `opacity` must be a number το `opacity` πρέπει να είναι αριθμός +@msg `type` must be "path" or "compound" το `type` πρέπει να είναι "path" ή "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters το `unit` πρέπει να έχει 1-{MAX_UNIT} εκτυπώσιμους χαρακτήρες +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` το `visibleWhen` πρέπει να αναφέρει 1 έως {MAX_CONDITION_VALUES} τιμές για το `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one το `visibleWhen` αναφέρει το `{other}`, που δεν είναι παράμετρος δηλωμένη πριν από αυτήν +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters το `visibleWhen` αναφέρει έως {MAX_CONDITIONS} παραμέτρους +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice το `visibleWhen` δέχεται τιμές του `{other}`, ενός πλαισίου ελέγχου ή μιας επιλογής +@msg `visible` must be true or false το `visible` πρέπει να είναι true ή false +@msg `{key}` must be a number from {_1} to {_2} το `{key}` πρέπει να είναι αριθμός από {_1} έως {_2} +@msg `{key}` must be an array of at most {MAX_ITEMS} entries το `{key}` πρέπει να είναι πίνακας έως {MAX_ITEMS} στοιχείων +@msg a color default must be a color object or a hex string such as "#3a7bd5" η προεπιλογή χρώματος πρέπει να είναι αντικείμενο χρώματος ή δεκαεξαδική συμβολοσειρά όπως "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" αντικείμενο χρώματος ή δεκαεξαδική συμβολοσειρά όπως "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines ένα ντεγκραντέ ελεύθερης μορφής έχει έως {MAX_ITEMS} σημεία και γραμμές +@msg a freeform gradient line names a point it doesn't have μια γραμμή ντεγκραντέ ελεύθερης μορφής αναφέρει σημείο που το ντεγκραντέ δεν έχει +@msg a gradient needs 1-{MAX_ITEMS} stops ένα ντεγκραντέ χρειάζεται 1-{MAX_ITEMS} σημεία χρώματος +@msg a list of numbers λίστα αριθμών +@msg a list of {min_items} to {max_items} numbers λίστα {min_items} έως {max_items} αριθμών +@msg a new object needs a `path` ένα νέο αντικείμενο χρειάζεται `path` +@msg a number αριθμός +@msg a painted live effect returns only `objects` ένα ζωντανό εφέ βαφής επιστρέφει μόνο `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` ένα κομμάτι ζωντανού εφέ βαφής έχει μόνο `path`, `fillRule`, `fills` και `strokes` +@msg a read-only object was left out παραλείφθηκε ένα αντικείμενο μόνο για ανάγνωση +@msg a read-only object was left out: return it, as {"id"} alone or as it was given παραλείφθηκε ένα αντικείμενο μόνο για ανάγνωση: επιστρέψτε το ως {"id"} μόνο ή όπως δόθηκε +@msg a string συμβολοσειρά +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` ένα περίγραμμα έχει μόνο `paint`, `width`, `join`, `cap` και `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} ένα περίγραμμα πρέπει να είναι {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} ένα εφέ πρέπει να είναι {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` ένα εφέ σε γέμισμα επιστρέφει μόνο `fills` +@msg an effect on a stroke returns only `strokes` ένα εφέ σε περίγραμμα επιστρέφει μόνο `strokes` +@msg an object must be a JSON object ένα αντικείμενο πρέπει να είναι αντικείμενο JSON +@msg bad range μη έγκυρο εύρος +@msg choices must be 1-64 non-empty, printable strings οι επιλογές πρέπει να είναι 1-64 μη κενές, εκτυπώσιμες συμβολοσειρές +@msg coordinate out of range (limit ±{MAX_COORD} pt) συντεταγμένη εκτός εύρους (όριο ±{MAX_COORD} pt) +@msg default is not one of the options η προεπιλογή δεν είναι μία από τις επιλογές +@msg each piece needs a `path` κάθε κομμάτι χρειάζεται `path` +@msg is listed twice αναφέρεται δύο φορές +@msg pattern transform out of range μετασχηματισμός μοτίβου εκτός εύρους +@msg read-only field '{field}' changed το πεδίο μόνο για ανάγνωση '{field}' άλλαξε +@msg stops must be in order of `offset` τα σημεία χρώματος πρέπει να είναι ταξινομημένα κατά `offset` +@msg the document has no pattern {pattern} το έγγραφο δεν έχει μοτίβο {pattern} +@msg the document has no swatch {n} το έγγραφο δεν έχει δείγμα χρώματος {n} +@msg the output η έξοδος +@msg the path has no anchor points η διαδρομή δεν έχει σημεία αγκύρωσης +@msg too many anchor points (limit {MAX_ANCHORS}) πάρα πολλά σημεία αγκύρωσης (όριο {MAX_ANCHORS}) +@msg true or false true ή false +@msg unknown key `{k}`; the keys here are {_1} άγνωστο κλειδί `{k}`· τα κλειδιά εδώ είναι {_1} +@msg {_1} is listed twice το {_1} αναφέρεται δύο φορές +@msg {_1} is not the id of an input object το {_1} δεν είναι αναγνωριστικό αντικειμένου εισόδου +@msg {_1} is read-only το {_1} είναι μόνο για ανάγνωση +@msg {what} must be from {lo} to {hi} το {what} πρέπει να είναι από {lo} έως {hi} +@msg a color component συνιστώσα χρώματος +@msg a plate πλάκα εκτύπωσης # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Να εγκατασταθεί το «{name}», που ζητά αυτά τα δικαιώματα; ID: {id} Αναγνωριστικό: {id} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6d548f6743..b34cb08249 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6036,6 +6036,110 @@ axis Y Axis Eje Y @msg a live effect runs while the document is drawn, so it can't be `heavy` un efecto interactivo se ejecuta mientras se dibuja el documento, así que no puede ser `heavy` @msg an exporter always gets an export's budget: leave `budget` out un plugin de exportación siempre recibe el presupuesto de una exportación: omite `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” pide un presupuesto de cálculo grande (cada ejecución hasta {minutes} minutos y {times} veces las instrucciones habituales): una persona debe aprobarlo en la aplicación de escritorio, o pasa `allowHeavy: true` en una sesión iniciada con --allow-plugin-grants +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` debe tener 1-64 caracteres imprimibles +@msg `version`, `description` or `author` is too long `version`, `description` o `author` es demasiado largo +@msg `warnings` must be a list of strings `warnings` debe ser una lista de cadenas +@msg `{k}` must be one of {_1} `{k}` debe ser uno de estos: {_1} +@msg `{k}` must be {what} `{k}` debe ser {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` un plugin de exportación declara de 1 a {MAX_EXTENSIONS} `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` como máximo {MAX_UNGROUPED} parámetros fuera de grupos: asigna un `group` a los demás +@msg can't encode the input no se puede codificar la entrada +@msg expected {"dataBase64"} se esperaba {"dataBase64"} +@msg expected {"objects"} se esperaba {"objects"} +@msg export `{name}`: {e} exportación del módulo `{name}`: {e} +@msg extension {e} is listed twice la extensión {e} aparece dos veces +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot extensión {e}: usa 1-16 letras minúsculas y dígitos, sin el punto +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - ID no válido {_1}: usa 1-64 caracteres de A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} nombre de parámetro no válido {k} +@msg manifest is not UTF-8 el manifiesto no está en UTF-8 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) el manifiesto es demasiado largo (límite {MAX_MANIFEST_BYTES} bytes) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) el manifiesto ocupa {_1} bytes (límite {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) el módulo ocupa {_1} bytes (límite {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own requiere aprobación: pide {0}, que el usuario concede al instalarlo por separado +@msg not JSON: {e} no es JSON: {e} +@msg not a WebAssembly binary (missing \0asm header) no es un binario WebAssembly (falta el encabezado \0asm) +@msg parameter `{k}`: {what} parámetro `{k}`: {what} +@msg parameter `{name}`: {what} parámetro `{name}`: {what} +@msg plug-in parameters must be a JSON object los parámetros del plugin deben ser un objeto JSON +@msg the module must export its linear memory as `memory` el módulo debe exportar su memoria lineal como `memory` +@msg the objects to change are no longer in the document los objetos que se iban a cambiar ya no están en el documento +@msg the runtime panicked el entorno de ejecución falló +@msg unexpected host trap error inesperado (trap) del host +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version devolvió {v}; este host implementa la versión {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc devolvió 0 (sin memoria) +@msg vc_alloc returned a block outside memory vc_alloc devolvió un bloque fuera de la memoria +@msg vc_run returned error code {packed} vc_run devolvió el código de error {packed} +@msg {_1} is not a file {_1} no es un archivo +@msg {_1} is {_2} bytes (limit {_3}) {_1} ocupa {_2} bytes (límite {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} objetos (límite {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} apunta fuera de la memoria +@msg `angle` must be a number `angle` debe ser un número +@msg `aspect` must be from 0.01 to 100 `aspect` debe estar entre 0.01 y 100 +@msg `cap` must be "butt", "round" or "square" `cap` debe ser "butt", "round" o "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` debe ser "nonzero" o "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` debe ser una dirección https:// de {MAX_HELP_URL} bytes como máximo +@msg `help` must be 1-{MAX_HELP} printable characters `help` debe tener 1-{MAX_HELP} caracteres imprimibles +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` debe ser un id de efecto de 1-{MAX_EFFECT_ID} bytes +@msg `id` must be the id of an input object `id` debe ser el id de un objeto de entrada +@msg `join` must be "miter", "round" or "bevel" `join` debe ser "miter", "round" o "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` y `group` deben tener 1-{MAX_LABEL} caracteres imprimibles +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` debe ser 1-{MAX_TEXT} y el valor predeterminado no puede ser más largo +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` y `maxItems` deben ser 0-{MAX_LIST}, `maxItems` al menos 1, y el valor predeterminado debe tener esa cantidad +@msg `name` must be a string `name` debe ser una cadena +@msg `opacity` must be a number `opacity` debe ser un número +@msg `type` must be "path" or "compound" `type` debe ser "path" o "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` debe tener 1-{MAX_UNIT} caracteres imprimibles +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` debe indicar de 1 a {MAX_CONDITION_VALUES} valores para `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` nombra `{other}`, que no es un parámetro declarado antes de este +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` nombra como máximo {MAX_CONDITIONS} parámetros +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` toma los valores de `{other}`, de una casilla de verificación o una opción +@msg `visible` must be true or false `visible` debe ser true o false +@msg `{key}` must be a number from {_1} to {_2} `{key}` debe ser un número de {_1} a {_2} +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` debe ser una lista de {MAX_ITEMS} entradas como máximo +@msg a color default must be a color object or a hex string such as "#3a7bd5" un color predeterminado debe ser un objeto de color o una cadena hexadecimal como "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" un objeto de color o una cadena hexadecimal como "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines un degradado de forma libre contiene como máximo {MAX_ITEMS} puntos y líneas +@msg a freeform gradient line names a point it doesn't have una línea del degradado de forma libre nombra un punto que el degradado no tiene +@msg a gradient needs 1-{MAX_ITEMS} stops un degradado necesita 1-{MAX_ITEMS} paradas +@msg a list of numbers una lista de números +@msg a list of {min_items} to {max_items} numbers una lista de {min_items} a {max_items} números +@msg a new object needs a `path` un objeto nuevo necesita un `path` +@msg a number un número +@msg a painted live effect returns only `objects` un efecto dinámico de pintura solo devuelve `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` una pieza de un efecto dinámico de pintura solo tiene `path`, `fillRule`, `fills` y `strokes` +@msg a read-only object was left out se omitió un objeto de solo lectura +@msg a read-only object was left out: return it, as {"id"} alone or as it was given se omitió un objeto de solo lectura: devuélvalo como {"id"} solo o tal como se recibió +@msg a string una cadena +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` un trazo solo tiene `paint`, `width`, `join`, `cap` y `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} un trazo debe ser {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} un efecto debe ser {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` un efecto en un relleno solo devuelve `fills` +@msg an effect on a stroke returns only `strokes` un efecto en un trazo solo devuelve `strokes` +@msg an object must be a JSON object un objeto debe ser un objeto JSON +@msg bad range rango no válido +@msg choices must be 1-64 non-empty, printable strings las opciones deben ser 1-64 cadenas imprimibles no vacías +@msg coordinate out of range (limit ±{MAX_COORD} pt) coordenada fuera de rango (límite ±{MAX_COORD} pt) +@msg default is not one of the options el valor predeterminado no es una de las opciones +@msg each piece needs a `path` cada pieza necesita un `path` +@msg is listed twice aparece dos veces +@msg pattern transform out of range transformación del motivo fuera de rango +@msg read-only field '{field}' changed cambió el campo de solo lectura '{field}' +@msg stops must be in order of `offset` las paradas deben estar en orden de `offset` +@msg the document has no pattern {pattern} el documento no tiene el motivo {pattern} +@msg the document has no swatch {n} el documento no tiene la muestra {n} +@msg the output la salida +@msg the path has no anchor points el trazado no tiene puntos de ancla +@msg too many anchor points (limit {MAX_ANCHORS}) demasiados puntos de ancla (límite {MAX_ANCHORS}) +@msg true or false true o false +@msg unknown key `{k}`; the keys here are {_1} clave desconocida `{k}`; las claves aquí son {_1} +@msg {_1} is listed twice {_1} aparece dos veces +@msg {_1} is not the id of an input object {_1} no es el id de un objeto de entrada +@msg {_1} is read-only {_1} es de solo lectura +@msg {what} must be from {lo} to {hi} {what} debe estar entre {lo} y {hi} +@msg a color component un componente de color +@msg a plate una plancha # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? ¿Instalar “{name}”, que pide estos permisos? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 7ae54d2bef..6d78d991c7 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6124,6 +6124,110 @@ axis Y Axis Y-akseli @msg a live effect runs while the document is drawn, so it can't be `heavy` live-tehoste suoritetaan asiakirjaa piirrettäessä, joten se ei voi olla `heavy` @msg an exporter always gets an export's budget: leave `budget` out vientilaajennus saa aina viennin budjetin: jätä `budget` pois @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants ”{_1}” pyytää suurta laskentabudjettia (jokainen ajo enintään {minutes} minuuttia ja {times} kertaa tavallinen määrä käskyjä): ihmisen on hyväksyttävä se työpöytäsovelluksessa, tai anna `allowHeavy: true` istunnossa, joka on käynnistetty valitsimella --allow-plugin-grants +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name`-kentässä on oltava 1-64 tulostettavaa merkkiä +@msg `version`, `description` or `author` is too long `version`, `description` tai `author` on liian pitkä +@msg `warnings` must be a list of strings `warnings` on oltava merkkijonojen luettelo +@msg `{k}` must be one of {_1} `{k}` on oltava jokin seuraavista: {_1} +@msg `{k}` must be {what} `{k}` on oltava {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` vientilaajennus ilmoittaa 1–{MAX_EXTENSIONS} `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` ryhmien ulkopuolella saa olla enintään {MAX_UNGROUPED} parametria: anna muille `group` +@msg can't encode the input syötettä ei voi koodata +@msg expected {"dataBase64"} odotettiin {"dataBase64"} +@msg expected {"objects"} odotettiin {"objects"} +@msg export `{name}`: {e} moduulin vienti `{name}`: {e} +@msg extension {e} is listed twice tunniste {e} on luettelossa kahdesti +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot tunniste {e}: käytä 1-16 pientä kirjainta ja numeroa ilman pistettä +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - virheellinen tunnus {_1}: käytä 1-64 merkkiä joukosta A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} virheellinen parametrin nimi {k} +@msg manifest is not UTF-8 manifesti ei ole UTF-8-muodossa +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) manifesti on liian pitkä (raja {MAX_MANIFEST_BYTES} tavua) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) manifesti on {_1} tavua (raja {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) moduuli on {_1} tavua (raja {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own vaatii hyväksynnän: se pyytää {0}, jotka käyttäjä myöntää asentaessaan sen erikseen +@msg not JSON: {e} ei JSON-muotoa: {e} +@msg not a WebAssembly binary (missing \0asm header) ei WebAssembly-binääri (\0asm-otsake puuttuu) +@msg parameter `{k}`: {what} parametri `{k}`: {what} +@msg parameter `{name}`: {what} parametri `{name}`: {what} +@msg plug-in parameters must be a JSON object laajennuksen parametrien on oltava JSON-objekti +@msg the module must export its linear memory as `memory` moduulin on vietävä lineaarinen muistinsa nimellä `memory` +@msg the objects to change are no longer in the document muutettavia objekteja ei enää ole asiakirjassa +@msg the runtime panicked suoritusympäristö kaatui +@msg unexpected host trap odottamaton keskeytys (trap) isännässä +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version palautti {v}; tämä isäntä toteuttaa version {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc palautti 0 (muisti loppui) +@msg vc_alloc returned a block outside memory vc_alloc palautti lohkon muistin ulkopuolelta +@msg vc_run returned error code {packed} vc_run palautti virhekoodin {packed} +@msg {_1} is not a file {_1} ei ole tiedosto +@msg {_1} is {_2} bytes (limit {_3}) {_1} on {_2} tavua (raja {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} objektia (raja {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} osoittaa muistin ulkopuolelle +@msg `angle` must be a number `angle` on oltava luku +@msg `aspect` must be from 0.01 to 100 `aspect` on oltava 0.01 ja 100 välillä +@msg `cap` must be "butt", "round" or "square" `cap` on oltava "butt", "round" tai "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` on oltava "nonzero" tai "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` on oltava enintään {MAX_HELP_URL} tavun https://-osoite +@msg `help` must be 1-{MAX_HELP} printable characters `help`-kentässä on oltava 1-{MAX_HELP} tulostettavaa merkkiä +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` on oltava 1-{MAX_EFFECT_ID} tavun tehostetunnus +@msg `id` must be the id of an input object `id` on oltava syöteobjektin tunnus +@msg `join` must be "miter", "round" or "bevel" `join` on oltava "miter", "round" tai "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label`- ja `group`-kentissä on oltava 1-{MAX_LABEL} tulostettavaa merkkiä +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` on oltava 1-{MAX_TEXT}, eikä oletusarvo saa olla pidempi +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` ja `maxItems` on oltava 0-{MAX_LIST}, `maxItems` vähintään 1, ja oletusarvossa on oltava niin monta kohdetta +@msg `name` must be a string `name` on oltava merkkijono +@msg `opacity` must be a number `opacity` on oltava luku +@msg `type` must be "path" or "compound" `type` on oltava "path" tai "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit`-kentässä on oltava 1-{MAX_UNIT} tulostettavaa merkkiä +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen`-kentässä on lueteltava 1-{MAX_CONDITION_VALUES} arvoa parametrille `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` nimeää parametrin `{other}`, jota ei ole esitelty ennen tätä parametria +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` nimeää enintään {MAX_CONDITIONS} parametria +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` ottaa parametrin `{other}` arvot, valintaruudun tai valinnan arvot +@msg `visible` must be true or false `visible` on oltava true tai false +@msg `{key}` must be a number from {_1} to {_2} `{key}` on oltava luku {_1} ja {_2} väliltä +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` on oltava enintään {MAX_ITEMS} kohteen taulukko +@msg a color default must be a color object or a hex string such as "#3a7bd5" värin oletusarvon on oltava väriobjekti tai heksamerkkijono, kuten "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" väriobjekti tai heksamerkkijono, kuten "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines vapaamuotoisessa liukuvärissä on enintään {MAX_ITEMS} pistettä ja viivaa +@msg a freeform gradient line names a point it doesn't have vapaamuotoisen liukuvärin viiva nimeää pisteen, jota liukuvärissä ei ole +@msg a gradient needs 1-{MAX_ITEMS} stops liukuväri tarvitsee 1-{MAX_ITEMS} väripistettä +@msg a list of numbers lukujen luettelo +@msg a list of {min_items} to {max_items} numbers {min_items}-{max_items} luvun luettelo +@msg a new object needs a `path` uusi objekti tarvitsee kentän `path` +@msg a number luku +@msg a painted live effect returns only `objects` maalaava live-tehoste palauttaa vain kentän `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` maalaavan live-tehosteen osalla on vain `path`, `fillRule`, `fills` ja `strokes` +@msg a read-only object was left out vain luku -objekti jätettiin pois +@msg a read-only object was left out: return it, as {"id"} alone or as it was given vain luku -objekti jätettiin pois: palauta se pelkkänä {"id"}-muotona tai sellaisena kuin se annettiin +@msg a string merkkijono +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` viivalla on vain `paint`, `width`, `join`, `cap` ja `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} viivan on oltava muotoa {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} tehosteen on oltava muotoa {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` täytön tehoste palauttaa vain kentän `fills` +@msg an effect on a stroke returns only `strokes` viivan tehoste palauttaa vain kentän `strokes` +@msg an object must be a JSON object objektin on oltava JSON-objekti +@msg bad range virheellinen alue +@msg choices must be 1-64 non-empty, printable strings vaihtoehtojen on oltava 1-64 ei-tyhjää, tulostettavaa merkkijonoa +@msg coordinate out of range (limit ±{MAX_COORD} pt) koordinaatti alueen ulkopuolella (raja ±{MAX_COORD} pt) +@msg default is not one of the options oletusarvo ei ole mikään vaihtoehdoista +@msg each piece needs a `path` jokainen osa tarvitsee kentän `path` +@msg is listed twice on luettelossa kahdesti +@msg pattern transform out of range kuvion muunnos alueen ulkopuolella +@msg read-only field '{field}' changed vain luku -kenttä '{field}' muuttui +@msg stops must be in order of `offset` väripisteiden on oltava `offset`-järjestyksessä +@msg the document has no pattern {pattern} asiakirjassa ei ole kuviota {pattern} +@msg the document has no swatch {n} asiakirjassa ei ole värimallia {n} +@msg the output tuloste +@msg the path has no anchor points polulla ei ole ankkuripisteitä +@msg too many anchor points (limit {MAX_ANCHORS}) liian monta ankkuripistettä (raja {MAX_ANCHORS}) +@msg true or false true tai false +@msg unknown key `{k}`; the keys here are {_1} tuntematon avain `{k}`; tässä käytettävät avaimet ovat {_1} +@msg {_1} is listed twice {_1} on luettelossa kahdesti +@msg {_1} is not the id of an input object {_1} ei ole syöteobjektin tunnus +@msg {_1} is read-only {_1} on vain luku -tilassa +@msg {what} must be from {lo} to {hi} {what}: arvon on oltava {lo} ja {hi} välillä +@msg a color component värikomponentti +@msg a plate painolevy # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Asennetaanko ”{name}”, joka pyytää näitä käyttöoikeuksia? ID: {id} Tunnus: {id} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 35c649f895..1141d030e4 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6089,6 +6089,110 @@ axis Y Axis Axe Y @msg a live effect runs while the document is drawn, so it can't be `heavy` un effet dynamique s’exécute pendant le tracé du document, il ne peut donc pas être `heavy` @msg an exporter always gets an export's budget: leave `budget` out un module externe d’exportation reçoit toujours le budget d’une exportation : omettez `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants « {_1} » demande un budget de calcul important (chaque exécution jusqu’à {minutes} minutes et {times} fois les instructions habituelles) : une personne doit l’approuver dans l’application de bureau, ou passez `allowHeavy: true` dans une session démarrée avec --allow-plugin-grants +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` doit compter 1-64 caractères imprimables +@msg `version`, `description` or `author` is too long `version`, `description` ou `author` est trop long +@msg `warnings` must be a list of strings `warnings` doit être une liste de chaînes +@msg `{k}` must be one of {_1} `{k}` doit valoir l’une de ces valeurs : {_1} +@msg `{k}` must be {what} `{k}` doit être {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` un module externe d’exportation déclare de 1 à {MAX_EXTENSIONS} `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` {MAX_UNGROUPED} paramètres au plus hors des groupes : donnez un `group` aux autres +@msg can't encode the input impossible d’encoder l’entrée +@msg expected {"dataBase64"} attendu : {"dataBase64"} +@msg expected {"objects"} attendu : {"objects"} +@msg export `{name}`: {e} export du module `{name}` : {e} +@msg extension {e} is listed twice l’extension {e} figure deux fois +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot extension {e} : utilisez 1-16 lettres minuscules et chiffres, sans le point +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - ID non valide {_1} : utilisez 1-64 caractères parmi A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} nom de paramètre non valide {k} +@msg manifest is not UTF-8 le manifeste n’est pas en UTF-8 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) le manifeste est trop long (limite {MAX_MANIFEST_BYTES} octets) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) le manifeste fait {_1} octets (limite {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) le module fait {_1} octets (limite {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own approbation requise : il demande {0}, que l’utilisateur accorde en l’installant seul +@msg not JSON: {e} pas du JSON : {e} +@msg not a WebAssembly binary (missing \0asm header) pas un binaire WebAssembly (en-tête \0asm absent) +@msg parameter `{k}`: {what} paramètre `{k}` : {what} +@msg parameter `{name}`: {what} paramètre `{name}` : {what} +@msg plug-in parameters must be a JSON object les paramètres du plug-in doivent être un objet JSON +@msg the module must export its linear memory as `memory` le module doit exporter sa mémoire linéaire sous le nom `memory` +@msg the objects to change are no longer in the document les objets à modifier ne sont plus dans le document +@msg the runtime panicked l’environnement d’exécution a planté +@msg unexpected host trap arrêt inattendu (trap) de l’hôte +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version a renvoyé {v} ; cet hôte implémente la version {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc a renvoyé 0 (mémoire épuisée) +@msg vc_alloc returned a block outside memory vc_alloc a renvoyé un bloc hors de la mémoire +@msg vc_run returned error code {packed} vc_run a renvoyé le code d’erreur {packed} +@msg {_1} is not a file {_1} n’est pas un fichier +@msg {_1} is {_2} bytes (limit {_3}) {_1} fait {_2} octets (limite {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} objets (limite {MAX_OBJECTS}) +@msg {at}: {msg} {at} : {msg} +@msg {what} points outside memory {what} pointe hors de la mémoire +@msg `angle` must be a number `angle` doit être un nombre +@msg `aspect` must be from 0.01 to 100 `aspect` doit être compris entre 0.01 et 100 +@msg `cap` must be "butt", "round" or "square" `cap` doit valoir "butt", "round" ou "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` doit valoir "nonzero" ou "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` doit être une adresse https:// d’au plus {MAX_HELP_URL} octets +@msg `help` must be 1-{MAX_HELP} printable characters `help` doit compter 1-{MAX_HELP} caractères imprimables +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` doit être un identifiant d’effet de 1-{MAX_EFFECT_ID} octets +@msg `id` must be the id of an input object `id` doit être l’identifiant d’un objet d’entrée +@msg `join` must be "miter", "round" or "bevel" `join` doit valoir "miter", "round" ou "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` et `group` doivent compter 1-{MAX_LABEL} caractères imprimables +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` doit valoir 1-{MAX_TEXT}, et la valeur par défaut ne doit pas être plus longue +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` et `maxItems` doivent valoir 0-{MAX_LIST}, `maxItems` au moins 1, et la valeur par défaut doit en compter autant +@msg `name` must be a string `name` doit être une chaîne +@msg `opacity` must be a number `opacity` doit être un nombre +@msg `type` must be "path" or "compound" `type` doit valoir "path" ou "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` doit compter 1-{MAX_UNIT} caractères imprimables +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` doit indiquer 1 à {MAX_CONDITION_VALUES} valeurs pour `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` nomme `{other}`, qui n’est pas un paramètre déclaré avant celui-ci +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` nomme au plus {MAX_CONDITIONS} paramètres +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` prend les valeurs de `{other}`, celles d’une case à cocher ou d’un choix +@msg `visible` must be true or false `visible` doit valoir true ou false +@msg `{key}` must be a number from {_1} to {_2} `{key}` doit être un nombre de {_1} à {_2} +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` doit être un tableau d’au plus {MAX_ITEMS} entrées +@msg a color default must be a color object or a hex string such as "#3a7bd5" une couleur par défaut doit être un objet couleur ou une chaîne hexadécimale comme "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" un objet couleur ou une chaîne hexadécimale comme "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines un dégradé de forme libre contient au plus {MAX_ITEMS} points et lignes +@msg a freeform gradient line names a point it doesn't have une ligne du dégradé de forme libre nomme un point que le dégradé n’a pas +@msg a gradient needs 1-{MAX_ITEMS} stops un dégradé nécessite 1-{MAX_ITEMS} étapes +@msg a list of numbers une liste de nombres +@msg a list of {min_items} to {max_items} numbers une liste de {min_items} à {max_items} nombres +@msg a new object needs a `path` un nouvel objet nécessite un `path` +@msg a number un nombre +@msg a painted live effect returns only `objects` un effet dynamique de peinture renvoie seulement `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` une pièce d’un effet dynamique de peinture a seulement `path`, `fillRule`, `fills` et `strokes` +@msg a read-only object was left out un objet en lecture seule a été omis +@msg a read-only object was left out: return it, as {"id"} alone or as it was given un objet en lecture seule a été omis : renvoyez-le, sous la forme {"id"} seul ou tel qu’il a été fourni +@msg a string une chaîne +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` un contour a seulement `paint`, `width`, `join`, `cap` et `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} un contour doit être {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} un effet doit être {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` un effet sur un fond renvoie seulement `fills` +@msg an effect on a stroke returns only `strokes` un effet sur un contour renvoie seulement `strokes` +@msg an object must be a JSON object un objet doit être un objet JSON +@msg bad range plage non valide +@msg choices must be 1-64 non-empty, printable strings les choix doivent être 1-64 chaînes imprimables non vides +@msg coordinate out of range (limit ±{MAX_COORD} pt) coordonnée hors limites (limite ±{MAX_COORD} pt) +@msg default is not one of the options la valeur par défaut ne fait pas partie des options +@msg each piece needs a `path` chaque pièce nécessite un `path` +@msg is listed twice figure deux fois +@msg pattern transform out of range transformation du motif hors limites +@msg read-only field '{field}' changed le champ en lecture seule '{field}' a été modifié +@msg stops must be in order of `offset` les étapes doivent être classées par `offset` +@msg the document has no pattern {pattern} le document n’a pas de motif {pattern} +@msg the document has no swatch {n} le document n’a pas de nuance {n} +@msg the output la sortie +@msg the path has no anchor points le tracé n’a aucun point d’ancrage +@msg too many anchor points (limit {MAX_ANCHORS}) trop de points d’ancrage (limite {MAX_ANCHORS}) +@msg true or false true ou false +@msg unknown key `{k}`; the keys here are {_1} clé inconnue `{k}` ; les clés ici sont {_1} +@msg {_1} is listed twice {_1} figure deux fois +@msg {_1} is not the id of an input object {_1} n’est pas l’identifiant d’un objet d’entrée +@msg {_1} is read-only {_1} est en lecture seule +@msg {what} must be from {lo} to {hi} {what} doit être compris entre {lo} et {hi} +@msg a color component une composante de couleur +@msg a plate une plaque # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Installer « {name} », qui demande ces autorisations ? ID: {id} ID : {id} diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 335b0c99f0..90fdb0e939 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5409,6 +5409,73 @@ antiAlias Sharp शार्प @id pathfinder.add मिलाएँ @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” अधिक संसाधन माँगता है (हर रन में {minutes} मिनट और सामान्य से {times} गुना निर्देश): इसे डेस्कटॉप ऐप में किसी व्यक्ति को स्वीकृत करना होगा, या --allow-plugin-grants से शुरू किए गए सत्र में `allowHeavy: true` दें @msg "{0}" is not a colour: write it as #rrggbb "{0}" एक रंग नहीं है: इसे #rrggbb के रूप में लिखें +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg needs approval: it asks for {0}, which the user grants when installing it on its own स्वीकृति आवश्यक: यह {0} माँगता है, जिसे उपयोगकर्ता इसे अलग से स्थापित करते समय देता है +@msg `angle` must be a number `angle` एक संख्या होना चाहिए +@msg `aspect` must be from 0.01 to 100 `aspect` 0.01 से 100 तक होना चाहिए +@msg `cap` must be "butt", "round" or "square" `cap` "butt", "round" या "square" होना चाहिए +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` "nonzero" या "evenodd" होना चाहिए +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` अधिकतम {MAX_HELP_URL} बाइट का https:// पता होना चाहिए +@msg `help` must be 1-{MAX_HELP} printable characters `help` में 1-{MAX_HELP} प्रिंट करने योग्य वर्ण होने चाहिए +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` 1-{MAX_EFFECT_ID} बाइट का इफ़ेक्ट id होना चाहिए +@msg `id` must be the id of an input object `id` किसी इनपुट ऑब्जेक्ट का id होना चाहिए +@msg `join` must be "miter", "round" or "bevel" `join` "miter", "round" या "bevel" होना चाहिए +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` और `group` में 1-{MAX_LABEL} प्रिंट करने योग्य वर्ण होने चाहिए +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` 1-{MAX_TEXT} होना चाहिए, और डिफ़ॉल्ट उससे लंबा नहीं होना चाहिए +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` और `maxItems` 0-{MAX_LIST} होने चाहिए, `maxItems` कम से कम 1, और डिफ़ॉल्ट में उतने ही आइटम होने चाहिए +@msg `name` must be a string `name` एक स्ट्रिंग होना चाहिए +@msg `opacity` must be a number `opacity` एक संख्या होना चाहिए +@msg `type` must be "path" or "compound" `type` "path" या "compound" होना चाहिए +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` में 1-{MAX_UNIT} प्रिंट करने योग्य वर्ण होने चाहिए +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` में `{other}` के लिए 1 से {MAX_CONDITION_VALUES} मान होने चाहिए +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` में `{other}` दिया गया है, जो इससे पहले घोषित पैरामीटर नहीं है +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` में अधिकतम {MAX_CONDITIONS} पैरामीटर हो सकते हैं +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` `{other}` के मान लेता है, जो चेकबॉक्स या विकल्प होना चाहिए +@msg `visible` must be true or false `visible` true या false होना चाहिए +@msg `{key}` must be a number from {_1} to {_2} `{key}` {_1} से {_2} तक की संख्या होना चाहिए +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` अधिकतम {MAX_ITEMS} प्रविष्टियों का ऐरे होना चाहिए +@msg a color default must be a color object or a hex string such as "#3a7bd5" रंग का डिफ़ॉल्ट एक रंग ऑब्जेक्ट या "#3a7bd5" जैसी हेक्स स्ट्रिंग होना चाहिए +@msg a color object or a hex string such as "#3a7bd5" एक रंग ऑब्जेक्ट या "#3a7bd5" जैसी हेक्स स्ट्रिंग +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines फ्रीफॉर्म ग्रेडिएंट में अधिकतम {MAX_ITEMS} पॉइंट और लाइनें होती हैं +@msg a freeform gradient line names a point it doesn't have फ्रीफॉर्म ग्रेडिएंट की एक लाइन ऐसे पॉइंट का नाम देती है जो ग्रेडिएंट में नहीं है +@msg a gradient needs 1-{MAX_ITEMS} stops ग्रेडिएंट को 1-{MAX_ITEMS} स्टॉप चाहिए +@msg a list of numbers संख्याओं की एक सूची +@msg a list of {min_items} to {max_items} numbers {min_items} से {max_items} संख्याओं की एक सूची +@msg a new object needs a `path` नए ऑब्जेक्ट को `path` चाहिए +@msg a number एक संख्या +@msg a painted live effect returns only `objects` पेंट करने वाला लाइव इफ़ेक्ट केवल `objects` लौटाता है +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` पेंट करने वाले लाइव इफ़ेक्ट के हिस्से में केवल `path`, `fillRule`, `fills` और `strokes` होते हैं +@msg a read-only object was left out एक केवल-पढ़ने योग्य ऑब्जेक्ट छोड़ दिया गया +@msg a read-only object was left out: return it, as {"id"} alone or as it was given एक केवल-पढ़ने योग्य ऑब्जेक्ट छोड़ दिया गया: इसे केवल {"id"} के रूप में या जैसा दिया गया था वैसा लौटाएँ +@msg a string एक स्ट्रिंग +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` स्ट्रोक में केवल `paint`, `width`, `join`, `cap` और `miterLimit` होते हैं +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} स्ट्रोक {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} होना चाहिए +@msg an effect must be {"id", "params"?, "visible"?} इफ़ेक्ट {"id", "params"?, "visible"?} होना चाहिए +@msg an effect on a fill returns only `fills` फ़िल पर इफ़ेक्ट केवल `fills` लौटाता है +@msg an effect on a stroke returns only `strokes` स्ट्रोक पर इफ़ेक्ट केवल `strokes` लौटाता है +@msg an object must be a JSON object ऑब्जेक्ट एक JSON ऑब्जेक्ट होना चाहिए +@msg bad range अमान्य सीमा +@msg choices must be 1-64 non-empty, printable strings विकल्प 1-64 खाली नहीं, प्रिंट करने योग्य स्ट्रिंग होने चाहिए +@msg coordinate out of range (limit ±{MAX_COORD} pt) निर्देशांक सीमा से बाहर है (सीमा ±{MAX_COORD} pt) +@msg default is not one of the options डिफ़ॉल्ट विकल्पों में से कोई नहीं है +@msg each piece needs a `path` हर हिस्से को `path` चाहिए +@msg is listed twice दो बार सूचीबद्ध है +@msg pattern transform out of range पैटर्न ट्रांसफ़ॉर्म सीमा से बाहर है +@msg read-only field '{field}' changed केवल-पढ़ने योग्य फ़ील्ड '{field}' बदल गया +@msg stops must be in order of `offset` स्टॉप `offset` के क्रम में होने चाहिए +@msg the document has no pattern {pattern} डॉक्यूमेंट में पैटर्न {pattern} नहीं है +@msg the document has no swatch {n} डॉक्यूमेंट में स्वॉच {n} नहीं है +@msg the output आउटपुट +@msg the path has no anchor points पाथ में कोई एंकर पॉइंट नहीं है +@msg too many anchor points (limit {MAX_ANCHORS}) बहुत अधिक एंकर पॉइंट (सीमा {MAX_ANCHORS}) +@msg true or false true या false +@msg unknown key `{k}`; the keys here are {_1} अज्ञात कुंजी `{k}`; यहाँ की कुंजियाँ {_1} हैं +@msg {_1} is listed twice {_1} दो बार सूचीबद्ध है +@msg {_1} is not the id of an input object {_1} किसी इनपुट ऑब्जेक्ट का id नहीं है +@msg {_1} is read-only {_1} केवल-पढ़ने योग्य है +@msg {what} must be from {lo} to {hi} {what} {lo} से {hi} तक होना चाहिए +@msg a color component एक रंग घटक +@msg a plate एक प्लेट # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? "{name}" स्थापित करें, जो ये अनुमतियां मांगता है? ID: {id} ID: {id} @@ -5505,7 +5572,7 @@ antiAlias Sharp शार्प @msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} समाप्त हो गया, लेकिन डॉक्यूमेंट या प्लग-इन इस बीच बदल गया: इसे फिर से चलाएं @msg {_1} font families available {_1} फ़ॉन्ट परिवारों उपलब्ध @msg {_1} has no {_2} style {_1} में कोई {_2} स्टाइल नहीं है -@msg {_1} is {_2} bytes (limit {_3}) {_1} {_2} बाइट्स (limit {_3}) है। +@msg {_1} is {_2} bytes (limit {_3}) {_1} {_2} बाइट्स का है (सीमा {_3}) @msg {_1} is a live effect (Effect › Plug-ins) {_1} एक लाइव इफ़ेक्ट (इफ़ेक्टी> प्लग-इन) है। @msg {_1} is already used by {_2} {_1} का उपयोग पहले से ही {_2} द्वारा किया जाता है @msg {_1} is an exporter (File › Export › Export As…) {_1} एक निर्यातक (फ़ाइल> निर्यात › निर्यात As...) है। @@ -5514,7 +5581,7 @@ antiAlias Sharp शार्प @msg {_1} isn't a path {_1} एक पाथ नहीं है @msg {_1} MB of document, at most {_2} MB {_1} MB of document, at most {_2} MB @msg {_1} MB of images, at most {_2} MB {_1} MB of images, at most {_2} MB -@msg {_1} objects (limit {MAX_OBJECTS}) {_1} ऑब्जेक्ट्स (limit {MAX_OBJECTS}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} ऑब्जेक्ट्स (सीमा {MAX_OBJECTS}) @msg {_1} options: {e} {_1} ऑप्शन्स: {e} @msg {_1} paints nothing to rasterize {_1} कुछ भी नहीं पेंट करता है @msg {_1} passwords can have up to {MAX_LEGACY_PASSWORD} ASCII characters (PDF 1.7 and later take any) {_1} पासवर्ड के पास {MAX_LEGACY_PASSWORD} ASCII वर्ण (PDF 1.7) और बाद में कोई लेना) हो सकता है। @@ -5618,7 +5685,7 @@ antiAlias Sharp शार्प @msg {v} is not an asset id {v} एक परिसंपत्ति आईडी नहीं है @msg {what} can't be intertwined {what} अंतर नहीं किया जा सकता है @msg {what} doesn't fit the paper with its bleed and marks {what} अपने ब्लेड और मार्क्स के साथ पेपर को फिट नहीं करता है -@msg {what} points outside memory {what} स्मृति के बाहर अंक +@msg {what} points outside memory {what} मेमोरी के बाहर इंगित करता है @msg {what} would print on more than {MAX_TILES} tiles: scale it down {what} {MAX_TILES} टाइल्स से अधिक प्रिंट करेगा: इसे नीचे स्केल करें @msg `{_1}` can't be edited or removed `{_1}` को संपादित या हटाया नहीं जा सकता है @msg `{_1}` has no brushes to apply `{_1}` को लागू करने के लिए कोई ब्रश नहीं है @@ -5648,13 +5715,13 @@ antiAlias Sharp शार्प @msg `{k}` is true or false `{k}` true या false है @msg `{k}` isn't an export setting: {_1} `{k}` एक निर्यात सेटिंग नहीं है: {_1} @msg `{k}` must be [x, y] `{k}` होना चाहिए [x, y] -@msg `{k}` must be {what} `{k}` होना चाहिए {what} +@msg `{k}` must be {what} `{k}` {what} होना चाहिए @msg `{k}` must be {what}, not {v} `{k}` होना चाहिए {what}, नहीं {v} @msg `{k}` must be a byte offset `{k}` को एक बाइट ऑफ़सेट होना चाहिए -@msg `{k}` must be a number `{k}` होना चाहिए +@msg `{k}` must be a number `{k}` एक संख्या होना चाहिए @msg `{k}` must be a number of points `{k}` को कई अंक होना चाहिए @msg `{k}` must be a string `{k}` एक स्ट्रिंग होना चाहिए -@msg `{k}` must be a whole number `{k}` होना चाहिए +@msg `{k}` must be a whole number `{k}` एक पूर्ण संख्या होना चाहिए @msg `{k}` must be from {lo} to {hi} for {key} `{k}` होना चाहिए {lo} से {hi} {key} के लिए @msg `{k}` must be one of {_1} `{k}` को {_1} में से एक होना चाहिए @msg `{keep}` is not a side of this axis `{keep}` इस अक्ष का एक पक्ष नहीं है @@ -5672,13 +5739,13 @@ antiAlias Sharp शार्प @msg `{key}` must be a #rrggbb color `{key}` एक #rrggbb रंग होना चाहिए @msg `{key}` must be a #rrggbb colour `{key}` एक #rrggbb रंग होना चाहिए @msg `{key}` must be a non-negative integer `{key}` को एक गैर-नकारात्मक पूर्णांक होना चाहिए -@msg `{key}` must be a number `{key}` होना चाहिए -@msg `{key}` must be a number {_1}..{_2} `{key}` होना चाहिए a number {_1}.{_2} -@msg `{key}` must be a number not below 0 `{key}` को 0 से नीचे नहीं होना चाहिए -@msg `{key}` must be a number, not {v} `{key}` एक नंबर होना चाहिए, नहीं {v} +@msg `{key}` must be a number `{key}` एक संख्या होना चाहिए +@msg `{key}` must be a number {_1}..{_2} `{key}` {_1}..{_2} की एक संख्या होना चाहिए +@msg `{key}` must be a number not below 0 `{key}` 0 से कम नहीं होने वाली संख्या होना चाहिए +@msg `{key}` must be a number, not {v} `{key}` एक संख्या होना चाहिए, {v} नहीं @msg `{key}` must be a profile name or null `{key}` को प्रोफ़ाइल नाम या null होना चाहिए @msg `{key}` must be a string `{key}` एक स्ट्रिंग होना चाहिए -@msg `{key}` must be a whole number `{key}` होना चाहिए +@msg `{key}` must be a whole number `{key}` एक पूर्ण संख्या होना चाहिए @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` को {min} और {max} ({x}) के बीच पूरी संख्या होना चाहिए। @msg `{key}` must be a whole number of at least 0, not {n} `{key}` को कम से कम 0 की पूरी संख्या होना चाहिए, नहीं {n} @msg `{key}` must be an appearance item index (paint order) or null `{key}` एक उपस्थिति आइटम सूचकांक (पेंट ऑर्डर) या null होना चाहिए @@ -5886,7 +5953,7 @@ antiAlias Sharp शार्प @msg `mode` must be one of {_1} `mode` को {_1} में से एक होना चाहिए @msg `mojikumi` must be "none" or "lineEndHalf" `mojikumi` होना चाहिए "none" or "lineEndHalf" @msg `name` is empty `name` खाली है -@msg `name` must be 1-64 printable characters `name` को 1-64 प्रिंट करने योग्य वर्ण होना चाहिए +@msg `name` must be 1-64 printable characters `name` में 1-64 प्रिंट करने योग्य वर्ण होने चाहिए @msg `names` is a list of preset names `names` प्रीसेट नामों की सूची है @msg `names` lists preset names `names` प्रीसेट नामों की सूची देता है @msg `names` must be a list of names `names` को नामों की सूची होना चाहिए @@ -6093,7 +6160,7 @@ antiAlias Sharp शार्प @msg an artboard is listed twice एक आर्टबोर्ड को दो बार सूचीबद्ध किया गया है @msg an empty artboard has no width or height to scale to एक खाली आर्टबोर्ड में स्केल करने के लिए कोई चौड़ाई या ऊंचाई नहीं है @msg an exporter always gets an export's budget: leave `budget` out एक निर्यातक हमेशा एक निर्यात बजट हो जाता है: `budget` बाहर छोड़ दें -@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` एक निर्यातक ने 1 को {MAX_EXTENSIONS} `extensions` को घोषित किया +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` एक्सपोर्टर 1 से {MAX_EXTENSIONS} `extensions` घोषित करता है @msg an image has no pixels and was left out एक इमेज में कोई पिक्सेल नहीं है और इसे छोड़ दिया गया था @msg an image is too large for DXF and was left out DXF के लिए एक इमेज बहुत बड़ी है और इसे छोड़ दिया गया था @msg an image of {_1} MB, at most {_2} MB {_1} MB की एक इमेज, अधिकांश {_2} MB पर @@ -6147,7 +6214,7 @@ antiAlias Sharp शार्प @msg at most {MAX_MATCHES} matches are kept अधिकांश {MAX_MATCHES} मैचों को रखा जाता है @msg at most {MAX_POINTS} points {MAX_POINTS} पॉइंट पर @msg at most {MAX_QUEUE} files सबसे अधिक {MAX_QUEUE} फ़ाइलों में -@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` अधिकांश {MAX_UNGROUPED} मापदंडों पर समूहों के बाहर: दूसरों को `group` देना +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` समूहों के बाहर अधिकतम {MAX_UNGROUPED} पैरामीटर: बाकी को एक `group` दें @msg at must be [x, y] at: [x, y] होना चाहिए @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard इस पैमाने पर ड्राइंग {w} × {h} pt है, कैनवास से अधिक रखती है: एक छोटे पैमाने सिलेक्ट करें या इसे आर्टबोर्ड पर फिट करें @msg at/rect lie outside the canvas कैनवास के बाहर / सही झूठ @@ -6224,7 +6291,7 @@ antiAlias Sharp शार्प @msg can't add the editing data for other apps: {why} अन्य ऐप्स के लिए संपादन डेटा नहीं जोड़ सकते: {why} @msg can't edit the written PDF: {why} लिखित PDF संपादित नहीं कर सकता: {why} @msg can't encode the document: {0} डॉक्यूमेंट को एनकोड नहीं कर सकता: {0} -@msg can't encode the input इनपुट को एनकोड नहीं कर सकता +@msg can't encode the input इनपुट एनकोड नहीं किया जा सकता @msg can't encrypt the PDF: {why} PDF को एन्क्रिप्ट नहीं कर सकता: {why} @msg can't linearise the PDF: {why} PDF को रैखिक नहीं कर सकता: {why} @msg can't open `{_1}`: {_2} `{_1}` नहीं खोल सकते: {_2} @@ -6455,8 +6522,8 @@ antiAlias Sharp शार्प @msg Expand: no gradient fills विस्तार: कोई ग्रेडिएंट भरता नहीं @msg Expand: nothing to expand विस्तार: विस्तार करने के लिए कुछ नहीं @msg expanded SVG styles exceed the import size limit विस्तारित SVG स्टाइल्स आयात आकार सीमा से अधिक है -@msg expected {"dataBase64"} उम्मीद {"dataBase64"} -@msg expected {"objects"} उम्मीद {"objects"} +@msg expected {"dataBase64"} अपेक्षित: {"dataBase64"} +@msg expected {"objects"} अपेक्षित: {"objects"} @msg export `{name}`: {e} निर्यात `{name}`: {e} @msg Export cancelled निर्यात रद्द @msg Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft स्क्रीन के लिए एक्सपोर्ट png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai या vectorcraft लिखता है @@ -6651,7 +6718,7 @@ antiAlias Sharp शार्प @msg Invalid dimension font name अमान्य आयाम फ़ॉन्ट नाम @msg Invalid dimension offset अमान्य आयाम ऑफसेट @msg Invalid drawing scale अमान्य ड्राइंग स्केल -@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - Invalid id {_1}: A-Z a-z 0-9 से 1-64 अक्षरों का उपयोग करें। +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - अमान्य id {_1}: A-Z a-z 0-9 . _ - में से 1-64 वर्णों का उपयोग करें @msg invalid image data for `{0}` `{0}` के लिए अमान्य इमेज डेटा @msg invalid native panel set अमान्य मूल पैनल सेट @msg invalid object id {value}: expected a non-negative integer अमान्य ऑब्जेक्ट id {value}: एक गैर-नकारात्मक पूर्णांक की उम्मीद @@ -6753,9 +6820,9 @@ antiAlias Sharp शार्प @msg Loading VectorCraft… वेक्टर क्रेफ्ट लोड हो रहा है ... @msg Lock Workspace is not available in VectorCraft लॉक वर्कस्पेस VectorCraft में उपलब्ध नहीं है @msg Make Guides: select paths गाइड बनाना: पाथ सिलेक्ट करें -@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) प्रकटन {_1} बाइट्स (limit {MAX_MANIFEST_BYTES}) -@msg manifest is not UTF-8 manifest: प्रकट नहीं है UTF-8 -@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) प्रकटन बहुत लंबा है (सीमा {MAX_MANIFEST_BYTES} बाइट्स) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) मैनिफ़ेस्ट {_1} बाइट्स का है (सीमा {MAX_MANIFEST_BYTES}) +@msg manifest is not UTF-8 मैनिफ़ेस्ट UTF-8 में नहीं है +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) मैनिफ़ेस्ट बहुत लंबा है (सीमा {MAX_MANIFEST_BYTES} बाइट्स) @msg manifest: {e} प्रकटन: {e} @msg margin: a {m} pt margin leaves no imageable area on the paper मार्जिन: एक {m} pt मार्जिन कागज पर कोई इमेजेबल क्षेत्र नहीं छोड़ता है @msg mask art ends outside a mask dictionary मास्क कला एक मास्क शब्दकोश के बाहर समाप्त होता है @@ -6852,7 +6919,7 @@ antiAlias Sharp शार्प @msg mode is set, add or toggle, not `{other}` mode set, add या toggle है, `{other}` नहीं @msg mode must be "cmyk" or "rgb" mode: मोड "cmyk" या "rgb" होना चाहिए @msg mode must be area|onPath, got {o} mode: मोड होना चाहिए क्षेत्रफल (onPath), {o} मिला -@msg module is {_1} bytes (limit {_2}) मॉड्यूल {_1} बाइट्स (सीम {_2}) है। +@msg module is {_1} bytes (limit {_2}) मॉड्यूल {_1} बाइट्स का है (सीमा {_2}) @msg more than {MAX_BATCH_FILES} data sets {MAX_BATCH_FILES} डेटा सेट से अधिक @msg more than {MAX_BATCH_FILES} files {MAX_BATCH_FILES} फ़ाइलों से अधिक @msg more than {MAX_CSV_COLS} variables: Import Data Sets reads at most {MAX_CSV_COLS} {MAX_CSV_COLS} से अधिक चर: आयात डेटा सेट अधिकांश {MAX_CSV_COLS} पर पढ़ा जाता है। @@ -7148,7 +7215,7 @@ antiAlias Sharp शार्प @msg not a valid SVGZ file: {e} एक मान्य SVGZ फ़ाइल नहीं: {e} @msg not a VectorCraft file: {0} एक वेक्टर क्रेफ्ट फ़ाइल नहीं: {0} @msg not a VectorCraft shortcut set (missing `overrides`) एक VectorCraft शॉर्टकट सेट नहीं है ( `overrides`) -@msg not a WebAssembly binary (missing \0asm header) नहीं एक WebAssembly द्विआधारी (missing \0asm header) +@msg not a WebAssembly binary (missing \0asm header) WebAssembly बाइनरी नहीं है (\0asm हेडर नहीं है) @msg not a Word document: its compound file has no WordDocument stream एक वर्ड डॉक्यूमेंट नहीं: इसकी यौगिक फ़ाइल में WordDocument स्ट्रीम नहीं है @msg not a Word document: its main part is not a WordprocessingML document वर्ड डॉक्यूमेंट नहीं: इसका मुख्य हिस्सा वर्डप्रोसेसिंगएमएल डॉक्यूमेंट नहीं है @msg not a Word document: its package has no main document part नहीं एक वर्ड डॉक्यूमेंट: इसके पैकेज में कोई मुख्य डॉक्यूमेंट हिस्सा नहीं है @@ -7168,7 +7235,7 @@ antiAlias Sharp शार्प @msg not editing an opacity mask एक opacity मास्क संपादित न करें @msg not found नहीं मिला @msg not in pattern editing mode पैटर्न संपादन मोड में नहीं -@msg not JSON: {e} नहीं JSON: {e} +@msg not JSON: {e} JSON नहीं है: {e} @msg not type on a path outside perspective दृष्टिकोण के बाहर एक पाथ पर टाइप न करें @msg nothing encoded कुछ भी नहीं @msg nothing in data set {name} was applied: no object is bound to a variable it has a value for डेटा सेट {name} में कुछ भी लागू नहीं किया गया था: कोई ऑब्जेक्ट एक चर के लिए बाध्य नहीं है, इसके लिए एक मूल्य है। @@ -7254,7 +7321,7 @@ antiAlias Sharp शार्प @msg Outline Stroke: no stroked paths selected आउटलाइन स्ट्रोक: सिलेक्टेड कोई स्ट्रोक पाथ नहीं @msg outlineText {v}: true or false outlineText {v}: true या false @msg output intent profile: {e} उत्पादन आशय प्रोफ़ाइल: {e} -@msg output size ({what} is {len} bytes, limit {max}) उत्पादन का आकार ({what} {len} बाइट्स है, {max} को सीमित करें) +@msg output size ({what} is {len} bytes, limit {max}) आउटपुट का आकार ({what} {len} बाइट्स का है, सीमा {max}) @msg output.destination: {_1} files are CMYK, and “{name}” is an RGB profile output.destination: {_1} फ़ाइलें CMYK हैं, और "{name}" एक RGB प्रोफ़ाइल है @msg output.destination: {e} output.destination: {e} @msg output.destination: no RGB or CMYK profile is called “{name}” output.destination: "{name}" नाम की कोई RGB या CMYK प्रोफ़ाइल नहीं है @@ -7325,13 +7392,13 @@ antiAlias Sharp शार्प @msg plane must be left|right|ground plane को left|right|ground होना चाहिए @msg plane must be left|right|ground|none plane को left|right|ground|none होना चाहिए @msg plug-in ABI: {0} प्लग-इन ABI: {0} -@msg plug-in exceeded its {0} प्लग-इन अपने {0} से अधिक है +@msg plug-in exceeded its {0} प्लग-इन ने अपना {0} पार कर लिया @msg plug-in failed: {0} प्लग-इन विफल: {0} -@msg plug-in manifest: {0} प्लग-इन प्रकटन: {0} +@msg plug-in manifest: {0} प्लग-इन मैनिफ़ेस्ट: {0} @msg plug-in output: {0} प्लग-इन आउटपुट: {0} @msg plug-in parameters must be a JSON object प्लग-इन पैरामीटर एक JSON ऑब्जेक्ट होना चाहिए @msg plug-in parameters: {0} प्लग-इन पैरामीटर: {0} -@msg plug-in trapped: {0} प्लग-इन फँसा: {0} +@msg plug-in trapped: {0} प्लग-इन में ट्रैप हुआ: {0} @msg Plug-ins can't be loaded from {path}: {e} प्लग-इन {path} से लोड नहीं किया जा सकता: {e} @msg point is outside the mesh बिंदु मेश के बाहर है @msg points must be [t, left, right] with non-negative widths points: गैर-नकारात्मक चौड़ाई के साथ अंक [t, left, right] होना चाहिए @@ -7753,7 +7820,7 @@ antiAlias Sharp शार्प @msg the object isn't on a perspective plane ऑब्जेक्ट एक पर्सपेक्टिव विमान पर नहीं है @msg the object would cross the horizon of the perspective plane ऑब्जेक्ट पर्सपेक्टिव विमान के क्षितिज को पार करेगी @msg the objects don't match the plan ऑब्जेक्ट्स प्लान से मेल नहीं खाते हैं -@msg the objects to change are no longer in the document ऑब्जेक्ट्स को बदलने के लिए अब डॉक्यूमेंट में नहीं हैं +@msg the objects to change are no longer in the document बदले जाने वाले ऑब्जेक्ट्स अब डॉक्यूमेंट में नहीं हैं @msg the objects would leave the grid ऑब्जेक्ट्स ग्रिड को छोड़ देंगे @msg the open and permissions passwords must differ खुला और अनुमति पासवर्ड अलग होना चाहिए @msg the open dialog has no Reset ओपन संवाद में कोई रीसेट नहीं है @@ -7813,7 +7880,7 @@ antiAlias Sharp शार्प @msg the replacement font is not available प्रतिस्थापन फ़ॉन्ट उपलब्ध नहीं है @msg the RTF file has too many paragraphs or formatting changes to place as type (at most {MAX_PARAGRAPHS} paragraphs and {MAX_RUNS} runs) RTF फ़ाइल में कई पैराग्राफ या प्रारूपण परिवर्तन होते हैं जो टाइप (अधिकांश {MAX_PARAGRAPHS} पैराग्राफ और {MAX_RUNS} रन पर) होते हैं। @msg the RTF file is too large to place as type (at most {_1} MB) RTF फ़ाइल बहुत बड़े प्रकार के रूप में जगह है (अधिकांश {_1} MB पर) -@msg the runtime panicked रनटाइम panicked +@msg the runtime panicked रनटाइम क्रैश हो गया @msg the Save PDF dialog didn't open सेव करें PDF संवाद खुला नहीं था @msg the scale is not a usable number स्केल एक प्रयोग योग्य संख्या नहीं है @msg the screen color sampler is already open स्क्रीन रंग नमूना पहले से ही खुला है @@ -7952,7 +8019,7 @@ antiAlias Sharp शार्प @msg type that changes writing direction inside a text object opens in the direction of its first character टेक्स्ट ऑब्जेक्ट के भीतर लेखन दिशा बदलने वाला टेक्स्ट अपने पहले वर्ण की दिशा में खुलता है @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip प्रकार जुड़े क्षेत्रों में एक मास्क के रूप में इस्तेमाल किया, रैपराउंड टेक्स्ट या एक मिश्रित पाथ क्लिप नहीं करता है @msg Unembedded to {_1} Unembed to {_1} -@msg unexpected host trap अप्रत्याशित मेजबान जाल +@msg unexpected host trap होस्ट में अप्रत्याशित ट्रैप @msg unexpected resources अप्रत्याशित संसाधन @msg unknown {k} key `{key}` (size, position) अज्ञात {k} कुंजी `{key}` (आकार, स्थिति) @msg unknown {key} `{v}` (none, short or full) अज्ञात {key} `{v}` (one, short or full) @@ -8093,9 +8160,9 @@ antiAlias Sharp शार्प @msg variables are locked चर लॉक कर रहे हैं @msg variables that aren't text were left out: {kinds} वेरिएबल जो टेक्स्ट नहीं छोड़े गए थे: {kinds} @msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version ने {v} लौटाया; यह होस्ट संस्करण {ABI_VERSION} लागू करता है -@msg vc_alloc returned 0 (out of memory) vc_alloc 0 लौटे (मेमोरी से बाहर) -@msg vc_alloc returned a block outside memory vc_alloc स्मृति के बाहर एक ब्लॉक लौटा -@msg vc_run returned error code {packed} vc_run त्रुटि कोड लौटा {packed} +@msg vc_alloc returned 0 (out of memory) vc_alloc ने 0 लौटाया (मेमोरी खत्म) +@msg vc_alloc returned a block outside memory vc_alloc ने मेमोरी के बाहर का ब्लॉक लौटाया +@msg vc_run returned error code {packed} vc_run ने त्रुटि कोड {packed} लौटाया @msg VectorCraft doesn't search other apps' or the system's folders: {path} वेक्टर क्रेफ्ट अन्य एप्लिकेशन या सिस्टम के फ़ोल्डर्स की खोज नहीं करता है: {path} @msg VectorCraft doesn't search the Shared or Public folder in Users: {path} वेक्टर क्रेफ्ट उपयोगकर्ताओं में साझा या सार्वजनिक फ़ोल्डर की खोज नहीं करता है: {path} @msg VectorCraft failed to start: {e} वेक्टर क्रेफ्ट शुरू करने में विफल रहा: {e} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c3ed22bce8..a485a10283 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6077,6 +6077,110 @@ axis Y Axis Y tengely @msg a live effect runs while the document is drawn, so it can't be `heavy` az élő effektus a dokumentum rajzolása közben fut, ezért nem lehet `heavy` @msg an exporter always gets an export's budget: leave `budget` out az exportáló bővítmény mindig az exportálás keretét kapja: hagyd ki a `budget` kulcsot @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants a(z) „{_1}” nagy számítási keretet kér (futásonként legfeljebb {minutes} perc és a szokásos utasításszám {times}-szorosa): egy személynek jóvá kell hagynia az asztali alkalmazásban, vagy add meg az `allowHeavy: true` értéket egy --allow-plugin-grants kapcsolóval indított munkamenetben +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters a `name` 1-64 nyomtatható karakter legyen +@msg `version`, `description` or `author` is too long a `version`, a `description` vagy az `author` túl hosszú +@msg `warnings` must be a list of strings a `warnings` karakterláncok listája legyen +@msg `{k}` must be one of {_1} `{k}`: ezek egyike kell: {_1} +@msg `{k}` must be {what} `{k}`: {what} kell +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` az exportáló bővítmény 1–{MAX_EXTENSIONS} `extensions` értéket ad meg +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` csoporton kívül legfeljebb {MAX_UNGROUPED} paraméter lehet: a többinek adj meg `group` értéket +@msg can't encode the input a bemenet nem kódolható +@msg expected {"dataBase64"} várt formátum: {"dataBase64"} +@msg expected {"objects"} várt formátum: {"objects"} +@msg export `{name}`: {e} `{name}` modulexport: {e} +@msg extension {e} is listed twice a(z) {e} kiterjesztés kétszer szerepel +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot {e} kiterjesztés: 1-16 kisbetűt és számjegyet használj, pont nélkül +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - érvénytelen azonosító: {_1}; 1-64 karaktert használj ezekből: A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} érvénytelen paraméternév: {k} +@msg manifest is not UTF-8 a leíró nem UTF-8 kódolású +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) a leíró túl hosszú (korlát: {MAX_MANIFEST_BYTES} bájt) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) a leíró mérete {_1} bájt (korlát: {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) a modul mérete {_1} bájt (korlát: {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own jóváhagyás szükséges: ezt kéri: {0}, amit a felhasználó a külön telepítéskor ad meg +@msg not JSON: {e} nem JSON: {e} +@msg not a WebAssembly binary (missing \0asm header) nem WebAssembly bináris (hiányzik a \0asm fejléc) +@msg parameter `{k}`: {what} `{k}` paraméter: {what} +@msg parameter `{name}`: {what} `{name}` paraméter: {what} +@msg plug-in parameters must be a JSON object a bővítmény paraméterei JSON-objektumot alkossanak +@msg the module must export its linear memory as `memory` a modulnak `memory` néven kell exportálnia a lineáris memóriáját +@msg the objects to change are no longer in the document a módosítandó objektumok már nincsenek a dokumentumban +@msg the runtime panicked a futtatókörnyezet összeomlott +@msg unexpected host trap váratlan megszakítás (trap) a gazdaprogramban +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} a vc_abi_version {v} értéket adott vissza; ez a gazdaprogram a(z) {ABI_VERSION}. verziót valósítja meg +@msg vc_alloc returned 0 (out of memory) a vc_alloc 0-t adott vissza (elfogyott a memória) +@msg vc_alloc returned a block outside memory a vc_alloc a memórián kívüli blokkot adott vissza +@msg vc_run returned error code {packed} a vc_run {packed} hibakódot adott vissza +@msg {_1} is not a file nem fájl: {_1} +@msg {_1} is {_2} bytes (limit {_3}) {_1}: {_2} bájt (korlát: {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} objektum (korlát: {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what}: a memórián kívülre mutat +@msg `angle` must be a number `angle`: szám kell +@msg `aspect` must be from 0.01 to 100 `aspect`: 0.01 és 100 közötti érték kell +@msg `cap` must be "butt", "round" or "square" `cap`: "butt", "round" vagy "square" kell +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule`: "nonzero" vagy "evenodd" kell +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl`: legfeljebb {MAX_HELP_URL} bájtos https:// cím kell +@msg `help` must be 1-{MAX_HELP} printable characters a `help` 1-{MAX_HELP} nyomtatható karakter legyen +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id`: 1-{MAX_EFFECT_ID} bájtos effektusazonosító kell +@msg `id` must be the id of an input object `id`: egy bemeneti objektum azonosítója kell +@msg `join` must be "miter", "round" or "bevel" `join`: "miter", "round" vagy "bevel" kell +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters a `label` és a `group` 1-{MAX_LABEL} nyomtatható karakter legyen +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer a `maxLength` 1-{MAX_TEXT} legyen, és az alapértelmezett érték ne legyen hosszabb +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many a `minItems` és a `maxItems` 0-{MAX_LIST} legyen, a `maxItems` legalább 1, és az alapértelmezett érték ennyi elemből álljon +@msg `name` must be a string `name`: karakterlánc kell +@msg `opacity` must be a number `opacity`: szám kell +@msg `type` must be "path" or "compound" `type`: "path" vagy "compound" kell +@msg `unit` must be 1-{MAX_UNIT} printable characters a `unit` 1-{MAX_UNIT} nyomtatható karakter legyen +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` a `visibleWhen` 1-{MAX_CONDITION_VALUES} értéket adjon meg ehhez: `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one a `visibleWhen` ezt nevezi meg: `{other}`, de ez nem az ennél korábban megadott paraméterek egyike +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters a `visibleWhen` legfeljebb {MAX_CONDITIONS} paramétert nevezhet meg +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice a `visibleWhen` a(z) `{other}` értékeit veszi át, egy jelölőnégyzetét vagy egy választásét +@msg `visible` must be true or false `visible`: true vagy false kell +@msg `{key}` must be a number from {_1} to {_2} `{key}`: {_1} és {_2} közötti szám kell +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}`: legfeljebb {MAX_ITEMS} elemű tömb kell +@msg a color default must be a color object or a hex string such as "#3a7bd5" a szín alapértelmezett értéke színobjektum vagy hexadecimális karakterlánc legyen, például "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" színobjektum vagy hexadecimális karakterlánc, például "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines a szabad formájú színátmenet legfeljebb {MAX_ITEMS} pontot és vonalat tartalmaz +@msg a freeform gradient line names a point it doesn't have a szabad formájú színátmenet egyik vonala olyan pontot nevez meg, amely nincs benne +@msg a gradient needs 1-{MAX_ITEMS} stops a színátmenethez 1-{MAX_ITEMS} színátmenet-pont kell +@msg a list of numbers számok listája +@msg a list of {min_items} to {max_items} numbers {min_items}-{max_items} számból álló lista +@msg a new object needs a `path` az új objektumhoz `path` kell +@msg a number szám +@msg a painted live effect returns only `objects` a festő élő effektus csak `objects` értéket ad vissza +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` a festő élő effektus darabjának csak `path`, `fillRule`, `fills` és `strokes` kulcsa van +@msg a read-only object was left out kimaradt egy írásvédett objektum +@msg a read-only object was left out: return it, as {"id"} alone or as it was given kimaradt egy írásvédett objektum: adja vissza önmagában {"id"} formában vagy úgy, ahogy megkapta +@msg a string karakterlánc +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` a körvonalnak csak `paint`, `width`, `join`, `cap` és `miterLimit` kulcsa van +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} a körvonal formája {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} legyen +@msg an effect must be {"id", "params"?, "visible"?} az effektus formája {"id", "params"?, "visible"?} legyen +@msg an effect on a fill returns only `fills` a kitöltésen lévő effektus csak `fills` értéket ad vissza +@msg an effect on a stroke returns only `strokes` a körvonalon lévő effektus csak `strokes` értéket ad vissza +@msg an object must be a JSON object az objektum JSON-objektum legyen +@msg bad range érvénytelen tartomány +@msg choices must be 1-64 non-empty, printable strings a választási lehetőségek 1-64 nem üres, nyomtatható karakterlánc legyenek +@msg coordinate out of range (limit ±{MAX_COORD} pt) tartományon kívüli koordináta (korlát: ±{MAX_COORD} pt) +@msg default is not one of the options az alapértelmezett érték nem a lehetőségek egyike +@msg each piece needs a `path` minden darabhoz `path` kell +@msg is listed twice kétszer szerepel +@msg pattern transform out of range tartományon kívüli mintázat-transzformáció +@msg read-only field '{field}' changed megváltozott a(z) '{field}' írásvédett mező +@msg stops must be in order of `offset` a színátmenet-pontok `offset` szerint rendezve legyenek +@msg the document has no pattern {pattern} a dokumentumban nincs {pattern} mintázat +@msg the document has no swatch {n} a dokumentumban nincs {n} színminta +@msg the output a kimenet +@msg the path has no anchor points az útvonalnak nincsenek horgonypontjai +@msg too many anchor points (limit {MAX_ANCHORS}) túl sok horgonypont (korlát: {MAX_ANCHORS}) +@msg true or false true vagy false +@msg unknown key `{k}`; the keys here are {_1} ismeretlen kulcs: `{k}`; az itt használható kulcsok: {_1} +@msg {_1} is listed twice {_1} kétszer szerepel +@msg {_1} is not the id of an input object {_1} nem egy bemeneti objektum azonosítója +@msg {_1} is read-only {_1} írásvédett +@msg {what} must be from {lo} to {hi} {what}: {lo} és {hi} közötti érték kell +@msg a color component színösszetevő +@msg a plate nyomólemez # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Telepíted a(z) „{name}” bővítményt, amely ezeket az engedélyeket kéri? ID: {id} Azonosító: {id} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a6edc31792..bbf73d0940 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5719,6 +5719,110 @@ axis Y Axis Asse Y @msg a live effect runs while the document is drawn, so it can't be `heavy` un effetto dinamico viene eseguito mentre il documento viene disegnato, quindi non può essere `heavy` @msg an exporter always gets an export's budget: leave `budget` out un plug-in di esportazione riceve sempre il budget di un’esportazione: ometti `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” chiede un budget di calcolo elevato (ogni esecuzione fino a {minutes} minuti e {times} volte le istruzioni consuete): una persona deve approvarlo nell’app desktop, oppure passa `allowHeavy: true` in una sessione avviata con --allow-plugin-grants +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` deve avere 1-64 caratteri stampabili +@msg `version`, `description` or `author` is too long `version`, `description` o `author` è troppo lungo +@msg `warnings` must be a list of strings `warnings` deve essere un elenco di stringhe +@msg `{k}` must be one of {_1} `{k}` deve essere uno dei seguenti: {_1} +@msg `{k}` must be {what} `{k}` deve essere {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` un plug-in di esportazione dichiara da 1 a {MAX_EXTENSIONS} `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` al massimo {MAX_UNGROUPED} parametri fuori dai gruppi: assegna agli altri un `group` +@msg can't encode the input impossibile codificare l’input +@msg expected {"dataBase64"} previsto {"dataBase64"} +@msg expected {"objects"} previsto {"objects"} +@msg export `{name}`: {e} esportazione del modulo `{name}`: {e} +@msg extension {e} is listed twice l’estensione {e} è elencata due volte +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot estensione {e}: usa 1-16 lettere minuscole e cifre, senza il punto +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - ID non valido {_1}: usa 1-64 caratteri tra A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} nome di parametro non valido {k} +@msg manifest is not UTF-8 il manifest non è in UTF-8 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) il manifest è troppo lungo (limite {MAX_MANIFEST_BYTES} byte) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) il manifest occupa {_1} byte (limite {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) il modulo occupa {_1} byte (limite {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own richiede approvazione: chiede {0}, che l’utente concede installandolo da solo +@msg not JSON: {e} non è JSON: {e} +@msg not a WebAssembly binary (missing \0asm header) non è un binario WebAssembly (manca l’intestazione \0asm) +@msg parameter `{k}`: {what} parametro `{k}`: {what} +@msg parameter `{name}`: {what} parametro `{name}`: {what} +@msg plug-in parameters must be a JSON object i parametri del plug-in devono essere un oggetto JSON +@msg the module must export its linear memory as `memory` il modulo deve esportare la sua memoria lineare come `memory` +@msg the objects to change are no longer in the document gli oggetti da modificare non sono più nel documento +@msg the runtime panicked l’ambiente di esecuzione si è bloccato +@msg unexpected host trap interruzione imprevista (trap) dell’host +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version ha restituito {v}; questo host implementa la versione {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc ha restituito 0 (memoria esaurita) +@msg vc_alloc returned a block outside memory vc_alloc ha restituito un blocco fuori dalla memoria +@msg vc_run returned error code {packed} vc_run ha restituito il codice di errore {packed} +@msg {_1} is not a file {_1} non è un file +@msg {_1} is {_2} bytes (limit {_3}) {_1} occupa {_2} byte (limite {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} oggetti (limite {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} punta fuori dalla memoria +@msg `angle` must be a number `angle` deve essere un numero +@msg `aspect` must be from 0.01 to 100 `aspect` deve essere compreso tra 0.01 e 100 +@msg `cap` must be "butt", "round" or "square" `cap` deve essere "butt", "round" o "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` deve essere "nonzero" o "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` deve essere un indirizzo https:// di al massimo {MAX_HELP_URL} byte +@msg `help` must be 1-{MAX_HELP} printable characters `help` deve avere 1-{MAX_HELP} caratteri stampabili +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` deve essere un id di effetto di 1-{MAX_EFFECT_ID} byte +@msg `id` must be the id of an input object `id` deve essere l’id di un oggetto di input +@msg `join` must be "miter", "round" or "bevel" `join` deve essere "miter", "round" o "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` e `group` devono avere 1-{MAX_LABEL} caratteri stampabili +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` deve essere 1-{MAX_TEXT} e il valore predefinito non può essere più lungo +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` e `maxItems` devono essere 0-{MAX_LIST}, `maxItems` almeno 1, e il valore predefinito deve averne altrettanti +@msg `name` must be a string `name` deve essere una stringa +@msg `opacity` must be a number `opacity` deve essere un numero +@msg `type` must be "path" or "compound" `type` deve essere "path" o "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` deve avere 1-{MAX_UNIT} caratteri stampabili +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` deve elencare da 1 a {MAX_CONDITION_VALUES} valori per `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` nomina `{other}`, che non è un parametro dichiarato prima di questo +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` nomina al massimo {MAX_CONDITIONS} parametri +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` accetta i valori di `{other}`, quelli di una casella di controllo o di una scelta +@msg `visible` must be true or false `visible` deve essere true o false +@msg `{key}` must be a number from {_1} to {_2} `{key}` deve essere un numero da {_1} a {_2} +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` deve essere un array di al massimo {MAX_ITEMS} voci +@msg a color default must be a color object or a hex string such as "#3a7bd5" un colore predefinito deve essere un oggetto colore o una stringa esadecimale come "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" un oggetto colore o una stringa esadecimale come "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines una sfumatura a forma libera contiene al massimo {MAX_ITEMS} punti e linee +@msg a freeform gradient line names a point it doesn't have una linea della sfumatura a forma libera nomina un punto che la sfumatura non ha +@msg a gradient needs 1-{MAX_ITEMS} stops una sfumatura richiede 1-{MAX_ITEMS} cursori colore +@msg a list of numbers un elenco di numeri +@msg a list of {min_items} to {max_items} numbers un elenco da {min_items} a {max_items} numeri +@msg a new object needs a `path` un nuovo oggetto richiede un `path` +@msg a number un numero +@msg a painted live effect returns only `objects` un effetto dinamico di pittura restituisce solo `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` un pezzo di un effetto dinamico di pittura ha solo `path`, `fillRule`, `fills` e `strokes` +@msg a read-only object was left out è stato omesso un oggetto di sola lettura +@msg a read-only object was left out: return it, as {"id"} alone or as it was given è stato omesso un oggetto di sola lettura: restituiscilo come {"id"} da solo o così come è stato fornito +@msg a string una stringa +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` una traccia ha solo `paint`, `width`, `join`, `cap` e `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} una traccia deve essere {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} un effetto deve essere {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` un effetto su un riempimento restituisce solo `fills` +@msg an effect on a stroke returns only `strokes` un effetto su una traccia restituisce solo `strokes` +@msg an object must be a JSON object un oggetto deve essere un oggetto JSON +@msg bad range intervallo non valido +@msg choices must be 1-64 non-empty, printable strings le scelte devono essere 1-64 stringhe stampabili non vuote +@msg coordinate out of range (limit ±{MAX_COORD} pt) coordinata fuori intervallo (limite ±{MAX_COORD} pt) +@msg default is not one of the options il valore predefinito non è una delle opzioni +@msg each piece needs a `path` ogni pezzo richiede un `path` +@msg is listed twice è elencato due volte +@msg pattern transform out of range trasformazione del motivo fuori intervallo +@msg read-only field '{field}' changed il campo di sola lettura '{field}' è cambiato +@msg stops must be in order of `offset` i cursori colore devono essere in ordine di `offset` +@msg the document has no pattern {pattern} il documento non ha il motivo {pattern} +@msg the document has no swatch {n} il documento non ha il campione {n} +@msg the output l’output +@msg the path has no anchor points il tracciato non ha punti di ancoraggio +@msg too many anchor points (limit {MAX_ANCHORS}) troppi punti di ancoraggio (limite {MAX_ANCHORS}) +@msg true or false true o false +@msg unknown key `{k}`; the keys here are {_1} chiave sconosciuta `{k}`; le chiavi qui sono {_1} +@msg {_1} is listed twice {_1} è elencato due volte +@msg {_1} is not the id of an input object {_1} non è l’id di un oggetto di input +@msg {_1} is read-only {_1} è di sola lettura +@msg {what} must be from {lo} to {hi} {what} deve essere compreso tra {lo} e {hi} +@msg a color component una componente colore +@msg a plate una lastra # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Installare “{name}”, che chiede queste autorizzazioni? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 65d7721aa1..8012739747 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5784,6 +5784,110 @@ axis Y Axis Y 軸 @msg a live effect runs while the document is drawn, so it can't be `heavy` ライブ効果はドキュメントの描画中に実行されるため、`heavy` にはできません @msg an exporter always gets an export's budget: leave `budget` out 書き出しプラグインには常に書き出しの予算が割り当てられます。`budget` は省略してください @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants 「{_1}」は大きな計算予算を求めています (1 回の実行につき最大 {minutes} 分、通常の {times} 倍の命令)。デスクトップ アプリで人が承認するか、--allow-plugin-grants を付けて開始したセッションで `allowHeavy: true` を指定してください +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` は印刷可能な 1-64 文字にしてください +@msg `version`, `description` or `author` is too long `version`、`description` または `author` が長すぎます +@msg `warnings` must be a list of strings `warnings` は文字列のリストにしてください +@msg `{k}` must be one of {_1} `{k}` は {_1} のいずれかにしてください +@msg `{k}` must be {what} `{k}` は {what} にしてください +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` 書き出しプラグインは `extensions` を 1~{MAX_EXTENSIONS} 個宣言します +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` グループ外のパラメーターは {MAX_UNGROUPED} 個までです。ほかのパラメーターには `group` を指定してください +@msg can't encode the input 入力をエンコードできません +@msg expected {"dataBase64"} {"dataBase64"} が必要です +@msg expected {"objects"} {"objects"} が必要です +@msg export `{name}`: {e} モジュールのエクスポート `{name}`: {e} +@msg extension {e} is listed twice 拡張子 {e} が 2 回指定されています +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot 拡張子 {e}: ドットを付けずに小文字と数字で 1-16 文字にしてください +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - ID {_1} は無効です。A-Z a-z 0-9 . _ - の 1-64 文字にしてください +@msg invalid parameter name {k} パラメーター名 {k} は無効です +@msg manifest is not UTF-8 マニフェストが UTF-8 ではありません +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) マニフェストが長すぎます (上限 {MAX_MANIFEST_BYTES} バイト) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) マニフェストは {_1} バイトです (上限 {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) モジュールは {_1} バイトです (上限 {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own 承認が必要です: {0} を要求しています。これはユーザーが単独でインストールするときに許可します +@msg not JSON: {e} JSON ではありません: {e} +@msg not a WebAssembly binary (missing \0asm header) WebAssembly バイナリではありません (\0asm ヘッダーがありません) +@msg parameter `{k}`: {what} パラメーター `{k}`: {what} +@msg parameter `{name}`: {what} パラメーター `{name}`: {what} +@msg plug-in parameters must be a JSON object プラグインのパラメーターは JSON オブジェクトにしてください +@msg the module must export its linear memory as `memory` モジュールは線形メモリを `memory` としてエクスポートする必要があります +@msg the objects to change are no longer in the document 変更するオブジェクトがドキュメントにもうありません +@msg the runtime panicked ランタイムが異常終了しました +@msg unexpected host trap ホストで予期しないトラップが発生しました +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version が {v} を返しました。このホストが実装するバージョンは {ABI_VERSION} です +@msg vc_alloc returned 0 (out of memory) vc_alloc が 0 を返しました (メモリ不足) +@msg vc_alloc returned a block outside memory vc_alloc がメモリの外のブロックを返しました +@msg vc_run returned error code {packed} vc_run がエラーコード {packed} を返しました +@msg {_1} is not a file {_1} はファイルではありません +@msg {_1} is {_2} bytes (limit {_3}) {_1} は {_2} バイトです (上限 {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) オブジェクトが {_1} 個あります (上限 {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} がメモリの外を指しています +@msg `angle` must be a number `angle` には数値を指定してください +@msg `aspect` must be from 0.01 to 100 `aspect` は 0.01 から 100 の範囲にしてください +@msg `cap` must be "butt", "round" or "square" `cap` は "butt"、"round"、"square" のいずれかにしてください +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` は "nonzero" または "evenodd" にしてください +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` は {MAX_HELP_URL} バイト以下の https:// アドレスにしてください +@msg `help` must be 1-{MAX_HELP} printable characters `help` は印刷可能な 1-{MAX_HELP} 文字にしてください +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` は 1-{MAX_EFFECT_ID} バイトの効果 ID にしてください +@msg `id` must be the id of an input object `id` は入力オブジェクトの ID にしてください +@msg `join` must be "miter", "round" or "bevel" `join` は "miter"、"round"、"bevel" のいずれかにしてください +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` と `group` は印刷可能な 1-{MAX_LABEL} 文字にしてください +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` は 1-{MAX_TEXT} にし、デフォルト値はそれより長くしないでください +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` と `maxItems` は 0-{MAX_LIST}、`maxItems` は 1 以上にし、デフォルト値の項目数をその範囲にしてください +@msg `name` must be a string `name` は文字列にしてください +@msg `opacity` must be a number `opacity` には数値を指定してください +@msg `type` must be "path" or "compound" `type` は "path" または "compound" にしてください +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` は印刷可能な 1-{MAX_UNIT} 文字にしてください +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` には `{other}` の値を 1 から {MAX_CONDITION_VALUES} 個指定してください +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` が指定した `{other}` は、このパラメーターより前に宣言されたパラメーターではありません +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` に指定できるパラメーターは {MAX_CONDITIONS} 個までです +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` が受け取るのは、チェックボックスか選択肢である `{other}` の値です +@msg `visible` must be true or false `visible` は true または false にしてください +@msg `{key}` must be a number from {_1} to {_2} `{key}` は {_1} から {_2} の数値にしてください +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` は {MAX_ITEMS} 項目以下の配列にしてください +@msg a color default must be a color object or a hex string such as "#3a7bd5" カラーのデフォルト値はカラーオブジェクトか "#3a7bd5" のような 16 進文字列にしてください +@msg a color object or a hex string such as "#3a7bd5" カラーオブジェクトか "#3a7bd5" のような 16 進文字列 +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines フリーフォームグラデーションのポイントとラインは {MAX_ITEMS} 個までです +@msg a freeform gradient line names a point it doesn't have フリーフォームグラデーションのラインが、グラデーションにないポイントを指定しています +@msg a gradient needs 1-{MAX_ITEMS} stops グラデーションには 1-{MAX_ITEMS} 個の分岐点が必要です +@msg a list of numbers 数値のリスト +@msg a list of {min_items} to {max_items} numbers {min_items} から {max_items} 個の数値のリスト +@msg a new object needs a `path` 新しいオブジェクトには `path` が必要です +@msg a number 数値 +@msg a painted live effect returns only `objects` ペイント型のライブ効果が返すのは `objects` だけです +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` ペイント型のライブ効果のピースに使えるのは `path`、`fillRule`、`fills`、`strokes` だけです +@msg a read-only object was left out 読み取り専用のオブジェクトが省かれています +@msg a read-only object was left out: return it, as {"id"} alone or as it was given 読み取り専用のオブジェクトが省かれています: {"id"} だけの形か、渡されたとおりの形で返してください +@msg a string 文字列 +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` 線に使えるのは `paint`、`width`、`join`、`cap`、`miterLimit` だけです +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 線は {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} の形にしてください +@msg an effect must be {"id", "params"?, "visible"?} 効果は {"id", "params"?, "visible"?} の形にしてください +@msg an effect on a fill returns only `fills` 塗りの効果が返すのは `fills` だけです +@msg an effect on a stroke returns only `strokes` 線の効果が返すのは `strokes` だけです +@msg an object must be a JSON object オブジェクトは JSON オブジェクトにしてください +@msg bad range 範囲が正しくありません +@msg choices must be 1-64 non-empty, printable strings 選択肢は空でない印刷可能な文字列 1-64 個にしてください +@msg coordinate out of range (limit ±{MAX_COORD} pt) 座標が範囲外です (上限 ±{MAX_COORD} pt) +@msg default is not one of the options デフォルト値が選択肢にありません +@msg each piece needs a `path` 各ピースには `path` が必要です +@msg is listed twice 2 回指定されています +@msg pattern transform out of range パターンの変形が範囲外です +@msg read-only field '{field}' changed 読み取り専用のフィールド '{field}' が変更されています +@msg stops must be in order of `offset` 分岐点は `offset` の順に並べてください +@msg the document has no pattern {pattern} ドキュメントにパターン {pattern} がありません +@msg the document has no swatch {n} ドキュメントにスウォッチ {n} がありません +@msg the output 出力 +@msg the path has no anchor points パスにアンカーポイントがありません +@msg too many anchor points (limit {MAX_ANCHORS}) アンカーポイントが多すぎます (上限 {MAX_ANCHORS}) +@msg true or false true または false +@msg unknown key `{k}`; the keys here are {_1} `{k}` は不明なキーです。ここで使えるキー: {_1} +@msg {_1} is listed twice {_1} が 2 回指定されています +@msg {_1} is not the id of an input object {_1} は入力オブジェクトの ID ではありません +@msg {_1} is read-only {_1} は読み取り専用です +@msg {what} must be from {lo} to {hi} {what} は {lo} から {hi} の範囲にしてください +@msg a color component カラー成分 +@msg a plate 版 # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? これらの権限を求める「{name}」をインストールしますか? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index eb686a14a0..6612bb4f25 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6045,6 +6045,110 @@ axis Y Axis Y ღერძი @msg a live effect runs while the document is drawn, so it can't be `heavy` ცოცხალი ეფექტი დოკუმენტის დახატვისას ეშვება, ამიტომ ის ვერ იქნება `heavy` @msg an exporter always gets an export's budget: leave `budget` out ექსპორტის პლაგინი ყოველთვის ექსპორტის ბიუჯეტს იღებს: გამოტოვეთ `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants „{_1}“ დიდ გამოთვლით ბიუჯეტს ითხოვს (თითო გაშვება {minutes} წუთამდე და ჩვეულებრივზე {times}-ჯერ მეტი ინსტრუქცია): ის დესკტოპის აპში ადამიანმა უნდა დაამტკიცოს, ან გადაეცით `allowHeavy: true` --allow-plugin-grants-ით გაშვებულ სესიაში +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` უნდა შედგებოდეს 1-64 დასაბეჭდი სიმბოლოსგან +@msg `version`, `description` or `author` is too long `version`, `description` ან `author` ზედმეტად გრძელია +@msg `warnings` must be a list of strings `warnings` უნდა იყოს სტრიქონების სია +@msg `{k}` must be one of {_1} `{k}` უნდა იყოს ერთ-ერთი: {_1} +@msg `{k}` must be {what} `{k}` უნდა იყოს {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` ექსპორტის პლაგინი აცხადებს 1-დან {MAX_EXTENSIONS}-მდე `extensions`-ს +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` ჯგუფების გარეთ მაქსიმუმ {MAX_UNGROUPED} პარამეტრია დასაშვები: დანარჩენებს მიეცით `group` +@msg can't encode the input შეყვანის კოდირება ვერ ხერხდება +@msg expected {"dataBase64"} მოსალოდნელი იყო {"dataBase64"} +@msg expected {"objects"} მოსალოდნელი იყო {"objects"} +@msg export `{name}`: {e} მოდულის ექსპორტი `{name}`: {e} +@msg extension {e} is listed twice გაფართოება {e} ორჯერაა მითითებული +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot გაფართოება {e}: გამოიყენეთ 1-16 პატარა ასო და ციფრი, წერტილის გარეშე +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - არასწორი ID {_1}: გამოიყენეთ 1-64 სიმბოლო A-Z a-z 0-9 . _ - ნაკრებიდან +@msg invalid parameter name {k} პარამეტრის არასწორი სახელი {k} +@msg manifest is not UTF-8 მანიფესტი UTF-8 კოდირებაში არ არის +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) მანიფესტი ზედმეტად გრძელია (ლიმიტი {MAX_MANIFEST_BYTES} ბაიტი) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) მანიფესტი {_1} ბაიტია (ლიმიტი {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) მოდული {_1} ბაიტია (ლიმიტი {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own საჭიროებს დამტკიცებას: ითხოვს {0}-ს, რასაც მომხმარებელი მისი ცალკე დაყენებისას აძლევს +@msg not JSON: {e} არ არის JSON: {e} +@msg not a WebAssembly binary (missing \0asm header) არ არის WebAssembly-ის ბინარული ფაილი (აკლია \0asm სათაური) +@msg parameter `{k}`: {what} პარამეტრი `{k}`: {what} +@msg parameter `{name}`: {what} პარამეტრი `{name}`: {what} +@msg plug-in parameters must be a JSON object პლაგინის პარამეტრები JSON ობიექტი უნდა იყოს +@msg the module must export its linear memory as `memory` მოდულმა თავისი წრფივი მეხსიერების ექსპორტი `memory` სახელით უნდა გააკეთოს +@msg the objects to change are no longer in the document შესაცვლელი ობიექტები დოკუმენტში აღარ არის +@msg the runtime panicked გაშვების გარემო ავარიულად შეჩერდა +@msg unexpected host trap ჰოსტის მოულოდნელი ავარიული შეჩერება (trap) +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version-მა დააბრუნა {v}; ეს ჰოსტი ვერსიას {ABI_VERSION} ახორციელებს +@msg vc_alloc returned 0 (out of memory) vc_alloc-მა დააბრუნა 0 (მეხსიერება ამოიწურა) +@msg vc_alloc returned a block outside memory vc_alloc-მა დააბრუნა ბლოკი მეხსიერების გარეთ +@msg vc_run returned error code {packed} vc_run-მა დააბრუნა შეცდომის კოდი {packed} +@msg {_1} is not a file {_1} ფაილი არ არის +@msg {_1} is {_2} bytes (limit {_3}) {_1} {_2} ბაიტია (ლიმიტი {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} ობიექტი (ლიმიტი {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} მეხსიერების გარეთ მიუთითებს +@msg `angle` must be a number `angle` უნდა იყოს რიცხვი +@msg `aspect` must be from 0.01 to 100 `aspect` უნდა იყოს 0.01-დან 100-მდე +@msg `cap` must be "butt", "round" or "square" `cap` უნდა იყოს "butt", "round" ან "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` უნდა იყოს "nonzero" ან "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` უნდა იყოს https:// მისამართი, არაუმეტეს {MAX_HELP_URL} ბაიტისა +@msg `help` must be 1-{MAX_HELP} printable characters `help` უნდა შედგებოდეს 1-{MAX_HELP} დასაბეჭდი სიმბოლოსგან +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` უნდა იყოს ეფექტის 1-{MAX_EFFECT_ID} ბაიტიანი ID +@msg `id` must be the id of an input object `id` უნდა იყოს შემავალი ობიექტის ID +@msg `join` must be "miter", "round" or "bevel" `join` უნდა იყოს "miter", "round" ან "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` და `group` უნდა შედგებოდეს 1-{MAX_LABEL} დასაბეჭდი სიმბოლოსგან +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` უნდა იყოს 1-{MAX_TEXT}, ხოლო ნაგულისხმევი მნიშვნელობა არ უნდა იყოს უფრო გრძელი +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` და `maxItems` უნდა იყოს 0-{MAX_LIST}, `maxItems` მინიმუმ 1, ხოლო ნაგულისხმევ მნიშვნელობაში უნდა იყოს ამდენი ელემენტი +@msg `name` must be a string `name` უნდა იყოს სტრიქონი +@msg `opacity` must be a number `opacity` უნდა იყოს რიცხვი +@msg `type` must be "path" or "compound" `type` უნდა იყოს "path" ან "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` უნდა შედგებოდეს 1-{MAX_UNIT} დასაბეჭდი სიმბოლოსგან +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen`-ში `{other}`-ისთვის უნდა იყოს 1-დან {MAX_CONDITION_VALUES}-მდე მნიშვნელობა +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` ასახელებს `{other}`-ს, რომელიც ამაზე ადრე გამოცხადებული პარამეტრი არ არის +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` ასახელებს არაუმეტეს {MAX_CONDITIONS} პარამეტრს +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` იღებს `{other}`-ის მნიშვნელობებს, მოსანიშნი ველის ან არჩევანისას +@msg `visible` must be true or false `visible` უნდა იყოს true ან false +@msg `{key}` must be a number from {_1} to {_2} `{key}` უნდა იყოს რიცხვი {_1}-დან {_2}-მდე +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` უნდა იყოს მასივი, არაუმეტეს {MAX_ITEMS} ელემენტით +@msg a color default must be a color object or a hex string such as "#3a7bd5" ფერის ნაგულისხმევი მნიშვნელობა უნდა იყოს ფერის ობიექტი ან თექვსმეტობითი სტრიქონი, მაგალითად "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" ფერის ობიექტი ან თექვსმეტობითი სტრიქონი, მაგალითად "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines თავისუფალი ფორმის გრადიენტი შეიცავს არაუმეტეს {MAX_ITEMS} წერტილსა და ხაზს +@msg a freeform gradient line names a point it doesn't have თავისუფალი ფორმის გრადიენტის ხაზი ასახელებს წერტილს, რომელიც გრადიენტს არ აქვს +@msg a gradient needs 1-{MAX_ITEMS} stops გრადიენტს სჭირდება 1-{MAX_ITEMS} გადასვლის წერტილი +@msg a list of numbers რიცხვების სია +@msg a list of {min_items} to {max_items} numbers {min_items}-დან {max_items}-მდე რიცხვის სია +@msg a new object needs a `path` ახალ ობიექტს სჭირდება `path` +@msg a number რიცხვი +@msg a painted live effect returns only `objects` მხატვრული ცოცხალი ეფექტი აბრუნებს მხოლოდ `objects`-ს +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` მხატვრული ცოცხალი ეფექტის ნაწილს აქვს მხოლოდ `path`, `fillRule`, `fills` და `strokes` +@msg a read-only object was left out გამოტოვებულია მხოლოდ წასაკითხი ობიექტი +@msg a read-only object was left out: return it, as {"id"} alone or as it was given გამოტოვებულია მხოლოდ წასაკითხი ობიექტი: დააბრუნეთ ის მხოლოდ {"id"}-ის სახით ან ისე, როგორც მიიღეთ +@msg a string სტრიქონი +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` კონტურს აქვს მხოლოდ `paint`, `width`, `join`, `cap` და `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} კონტური უნდა იყოს {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} ეფექტი უნდა იყოს {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` შევსების ეფექტი აბრუნებს მხოლოდ `fills`-ს +@msg an effect on a stroke returns only `strokes` კონტურის ეფექტი აბრუნებს მხოლოდ `strokes`-ს +@msg an object must be a JSON object ობიექტი უნდა იყოს JSON ობიექტი +@msg bad range არასწორი დიაპაზონი +@msg choices must be 1-64 non-empty, printable strings არჩევანი უნდა იყოს 1-64 არაცარიელი, დასაბეჭდი სტრიქონი +@msg coordinate out of range (limit ±{MAX_COORD} pt) კოორდინატი დიაპაზონს გარეთაა (ლიმიტი ±{MAX_COORD} pt) +@msg default is not one of the options ნაგულისხმევი მნიშვნელობა ვარიანტებს შორის არ არის +@msg each piece needs a `path` თითოეულ ნაწილს სჭირდება `path` +@msg is listed twice ორჯერაა მითითებული +@msg pattern transform out of range პატერნის გარდაქმნა დიაპაზონს გარეთაა +@msg read-only field '{field}' changed შეიცვალა მხოლოდ წასაკითხი ველი '{field}' +@msg stops must be in order of `offset` გადასვლის წერტილები `offset`-ის მიხედვით უნდა იყოს დალაგებული +@msg the document has no pattern {pattern} დოკუმენტს არ აქვს პატერნი {pattern} +@msg the document has no swatch {n} დოკუმენტს არ აქვს ფერის ნიმუში {n} +@msg the output გამოტანა +@msg the path has no anchor points ტრაექტორიას საყრდენი წერტილები არ აქვს +@msg too many anchor points (limit {MAX_ANCHORS}) ძალიან ბევრი საყრდენი წერტილი (ლიმიტი {MAX_ANCHORS}) +@msg true or false true ან false +@msg unknown key `{k}`; the keys here are {_1} უცნობი გასაღები `{k}`; აქ გასაღებებია: {_1} +@msg {_1} is listed twice {_1} ორჯერაა მითითებული +@msg {_1} is not the id of an input object {_1} შემავალი ობიექტის ID არ არის +@msg {_1} is read-only {_1} მხოლოდ წასაკითხია +@msg {what} must be from {lo} to {hi} {what} უნდა იყოს {lo}-დან {hi}-მდე +@msg a color component ფერის კომპონენტი +@msg a plate ფირფიტა # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? დავაყენოთ „{name}“, რომელიც ამ ნებართვებს ითხოვს? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 52a10ab8e8..d4fea2ff4b 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5680,6 +5680,110 @@ axis Y Axis Y축 @msg a live effect runs while the document is drawn, so it can't be `heavy` 라이브 효과는 문서를 그리는 동안 실행되므로 `heavy`일 수 없습니다 @msg an exporter always gets an export's budget: leave `budget` out 내보내기 플러그인은 항상 내보내기 예산을 받습니다. `budget`은 생략하세요 @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}”은(는) 큰 계산 예산을 요청합니다(실행당 최대 {minutes}분, 평소의 {times}배 명령어). 데스크톱 앱에서 사람이 승인하거나 --allow-plugin-grants로 시작한 세션에서 `allowHeavy: true`를 전달하세요 +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name`은(는) 인쇄 가능한 문자 1-64자여야 합니다 +@msg `version`, `description` or `author` is too long `version`, `description` 또는 `author`가 너무 깁니다 +@msg `warnings` must be a list of strings `warnings`는 문자열 목록이어야 합니다 +@msg `{k}` must be one of {_1} `{k}`은(는) {_1} 중 하나여야 합니다 +@msg `{k}` must be {what} `{k}`은(는) {what}이어야 합니다 +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` 내보내기 플러그인은 `extensions`를 1~{MAX_EXTENSIONS}개 선언합니다 +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` 그룹 밖의 매개변수는 최대 {MAX_UNGROUPED}개입니다. 나머지에는 `group`을 지정하세요 +@msg can't encode the input 입력을 인코딩할 수 없습니다 +@msg expected {"dataBase64"} {"dataBase64"} 형식이어야 합니다 +@msg expected {"objects"} {"objects"} 형식이어야 합니다 +@msg export `{name}`: {e} 모듈 내보내기 `{name}`: {e} +@msg extension {e} is listed twice 확장자 {e}이(가) 두 번 나열되었습니다 +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot 확장자 {e}: 점 없이 소문자와 숫자 1-16자를 사용하세요 +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - 잘못된 ID {_1}: A-Z a-z 0-9 . _ - 중에서 1-64자를 사용하세요 +@msg invalid parameter name {k} 잘못된 매개변수 이름 {k} +@msg manifest is not UTF-8 매니페스트가 UTF-8이 아닙니다 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) 매니페스트가 너무 깁니다(한도 {MAX_MANIFEST_BYTES}바이트) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) 매니페스트가 {_1}바이트입니다(한도 {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) 모듈이 {_1}바이트입니다(한도 {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own 승인이 필요합니다. {0}을(를) 요청하며, 사용자가 따로 설치할 때 허용합니다 +@msg not JSON: {e} JSON이 아닙니다: {e} +@msg not a WebAssembly binary (missing \0asm header) WebAssembly 바이너리가 아닙니다(\0asm 헤더 없음) +@msg parameter `{k}`: {what} 매개변수 `{k}`: {what} +@msg parameter `{name}`: {what} 매개변수 `{name}`: {what} +@msg plug-in parameters must be a JSON object 플러그인 매개변수는 JSON 개체여야 합니다 +@msg the module must export its linear memory as `memory` 모듈은 선형 메모리를 `memory`(으)로 내보내야 합니다 +@msg the objects to change are no longer in the document 변경할 개체가 더 이상 문서에 없습니다 +@msg the runtime panicked 런타임이 비정상 종료되었습니다 +@msg unexpected host trap 호스트에서 예기치 않은 트랩이 발생했습니다 +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version이 {v}을(를) 반환했습니다. 이 호스트는 버전 {ABI_VERSION}을(를) 구현합니다 +@msg vc_alloc returned 0 (out of memory) vc_alloc이 0을 반환했습니다(메모리 부족) +@msg vc_alloc returned a block outside memory vc_alloc이 메모리 밖의 블록을 반환했습니다 +@msg vc_run returned error code {packed} vc_run이 오류 코드 {packed}을(를) 반환했습니다 +@msg {_1} is not a file {_1}은(는) 파일이 아닙니다 +@msg {_1} is {_2} bytes (limit {_3}) {_1}이(가) {_2}바이트입니다(한도 {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) 개체 {_1}개(한도 {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what}이(가) 메모리 밖을 가리킵니다 +@msg `angle` must be a number `angle`은(는) 숫자여야 합니다 +@msg `aspect` must be from 0.01 to 100 `aspect`은(는) 0.01에서 100 사이여야 합니다 +@msg `cap` must be "butt", "round" or "square" `cap`은(는) "butt", "round" 또는 "square"여야 합니다 +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule`은(는) "nonzero" 또는 "evenodd"여야 합니다 +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl`은(는) {MAX_HELP_URL}바이트 이하의 https:// 주소여야 합니다 +@msg `help` must be 1-{MAX_HELP} printable characters `help`은(는) 인쇄 가능한 문자 1-{MAX_HELP}자여야 합니다 +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id`은(는) 1-{MAX_EFFECT_ID}바이트의 효과 ID여야 합니다 +@msg `id` must be the id of an input object `id`은(는) 입력 개체의 ID여야 합니다 +@msg `join` must be "miter", "round" or "bevel" `join`은(는) "miter", "round" 또는 "bevel"이어야 합니다 +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label`과 `group`은 인쇄 가능한 문자 1-{MAX_LABEL}자여야 합니다 +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength`은(는) 1-{MAX_TEXT}여야 하며 기본값은 그보다 길 수 없습니다 +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems`와 `maxItems`는 0-{MAX_LIST}, `maxItems`는 1 이상이어야 하며 기본값의 항목 수도 그 범위여야 합니다 +@msg `name` must be a string `name`은(는) 문자열이어야 합니다 +@msg `opacity` must be a number `opacity`은(는) 숫자여야 합니다 +@msg `type` must be "path" or "compound" `type`은(는) "path" 또는 "compound"여야 합니다 +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit`은(는) 인쇄 가능한 문자 1-{MAX_UNIT}자여야 합니다 +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen`에는 `{other}`의 값을 1-{MAX_CONDITION_VALUES}개 나열해야 합니다 +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen`이 지정한 `{other}`은(는) 이 매개변수보다 앞에 선언된 매개변수가 아닙니다 +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen`에는 매개변수를 최대 {MAX_CONDITIONS}개까지 지정할 수 있습니다 +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen`은 확인란 또는 선택 항목인 `{other}`의 값을 받습니다 +@msg `visible` must be true or false `visible`은(는) true 또는 false여야 합니다 +@msg `{key}` must be a number from {_1} to {_2} `{key}`은(는) {_1}에서 {_2} 사이의 숫자여야 합니다 +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}`은(는) 항목이 최대 {MAX_ITEMS}개인 배열이어야 합니다 +@msg a color default must be a color object or a hex string such as "#3a7bd5" 색상 기본값은 색상 개체 또는 "#3a7bd5" 같은 16진수 문자열이어야 합니다 +@msg a color object or a hex string such as "#3a7bd5" 색상 개체 또는 "#3a7bd5" 같은 16진수 문자열 +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines 자유형 그레이디언트에는 점과 선이 최대 {MAX_ITEMS}개까지 있습니다 +@msg a freeform gradient line names a point it doesn't have 자유형 그레이디언트의 선이 그레이디언트에 없는 점을 지정합니다 +@msg a gradient needs 1-{MAX_ITEMS} stops 그레이디언트에는 정지점이 1-{MAX_ITEMS}개 필요합니다 +@msg a list of numbers 숫자 목록 +@msg a list of {min_items} to {max_items} numbers 숫자 {min_items}-{max_items}개의 목록 +@msg a new object needs a `path` 새 개체에는 `path`가 필요합니다 +@msg a number 숫자 +@msg a painted live effect returns only `objects` 페인트형 라이브 효과는 `objects`만 반환합니다 +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` 페인트형 라이브 효과의 조각에는 `path`, `fillRule`, `fills`, `strokes`만 있습니다 +@msg a read-only object was left out 읽기 전용 개체가 빠졌습니다 +@msg a read-only object was left out: return it, as {"id"} alone or as it was given 읽기 전용 개체가 빠졌습니다: {"id"}만 있는 형태나 받은 그대로 반환하십시오 +@msg a string 문자열 +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` 선에는 `paint`, `width`, `join`, `cap`, `miterLimit`만 있습니다 +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 선은 {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 형태여야 합니다 +@msg an effect must be {"id", "params"?, "visible"?} 효과는 {"id", "params"?, "visible"?} 형태여야 합니다 +@msg an effect on a fill returns only `fills` 칠에 적용된 효과는 `fills`만 반환합니다 +@msg an effect on a stroke returns only `strokes` 선에 적용된 효과는 `strokes`만 반환합니다 +@msg an object must be a JSON object 개체는 JSON 개체여야 합니다 +@msg bad range 잘못된 범위 +@msg choices must be 1-64 non-empty, printable strings 선택 항목은 비어 있지 않은 인쇄 가능한 문자열 1-64개여야 합니다 +@msg coordinate out of range (limit ±{MAX_COORD} pt) 좌표가 범위를 벗어났습니다(한도 ±{MAX_COORD} pt) +@msg default is not one of the options 기본값이 옵션 중 하나가 아닙니다 +@msg each piece needs a `path` 각 조각에는 `path`가 필요합니다 +@msg is listed twice 두 번 나열되었습니다 +@msg pattern transform out of range 패턴 변형이 범위를 벗어났습니다 +@msg read-only field '{field}' changed 읽기 전용 필드 '{field}'이(가) 변경되었습니다 +@msg stops must be in order of `offset` 정지점은 `offset` 순서여야 합니다 +@msg the document has no pattern {pattern} 문서에 패턴 {pattern}이(가) 없습니다 +@msg the document has no swatch {n} 문서에 견본 {n}이(가) 없습니다 +@msg the output 출력 +@msg the path has no anchor points 패스에 앵커 포인트가 없습니다 +@msg too many anchor points (limit {MAX_ANCHORS}) 앵커 포인트가 너무 많습니다(한도 {MAX_ANCHORS}) +@msg true or false true 또는 false +@msg unknown key `{k}`; the keys here are {_1} 알 수 없는 키 `{k}`: 여기서 사용할 수 있는 키: {_1} +@msg {_1} is listed twice {_1}이(가) 두 번 나열되었습니다 +@msg {_1} is not the id of an input object {_1}은(는) 입력 개체의 ID가 아닙니다 +@msg {_1} is read-only {_1}은(는) 읽기 전용입니다 +@msg {what} must be from {lo} to {hi} {what}은(는) {lo}에서 {hi} 사이여야 합니다 +@msg a color component 색상 구성 요소 +@msg a plate 인쇄판 # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? 이 권한을 요청하는 “{name}”을(를) 설치하시겠습니까? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e44328480f..52c620c968 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5948,6 +5948,110 @@ axis Y Axis Oś Y @msg a live effect runs while the document is drawn, so it can't be `heavy` efekt na żywo działa podczas rysowania dokumentu, więc nie może być `heavy` @msg an exporter always gets an export's budget: leave `budget` out wtyczka eksportu zawsze dostaje budżet eksportu: pomiń `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants „{_1}” prosi o duży budżet obliczeń (każde uruchomienie do {minutes} minut i {times} razy więcej instrukcji niż zwykle): musi ją zatwierdzić osoba w aplikacji desktopowej albo przekaż `allowHeavy: true` w sesji uruchomionej z --allow-plugin-grants +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` musi mieć 1-64 drukowalnych znaków +@msg `version`, `description` or `author` is too long `version`, `description` lub `author` jest za długie +@msg `warnings` must be a list of strings `warnings` musi być listą ciągów znaków +@msg `{k}` must be one of {_1} `{k}` musi mieć jedną z wartości: {_1} +@msg `{k}` must be {what} `{k}` musi być {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` wtyczka eksportu deklaruje od 1 do {MAX_EXTENSIONS} `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` najwyżej {MAX_UNGROUPED} parametrów poza grupami: pozostałym nadaj `group` +@msg can't encode the input nie można zakodować danych wejściowych +@msg expected {"dataBase64"} oczekiwano {"dataBase64"} +@msg expected {"objects"} oczekiwano {"objects"} +@msg export `{name}`: {e} eksport modułu `{name}`: {e} +@msg extension {e} is listed twice rozszerzenie {e} występuje dwa razy +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot rozszerzenie {e}: użyj 1-16 małych liter i cyfr, bez kropki +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - nieprawidłowe ID {_1}: użyj 1-64 znaków spośród A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} nieprawidłowa nazwa parametru {k} +@msg manifest is not UTF-8 manifest nie jest w kodowaniu UTF-8 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) manifest jest za długi (limit {MAX_MANIFEST_BYTES} bajtów) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) manifest ma {_1} bajtów (limit {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) moduł ma {_1} bajtów (limit {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own wymaga zatwierdzenia: prosi o {0}, na co użytkownik zezwala, instalując ją osobno +@msg not JSON: {e} to nie jest JSON: {e} +@msg not a WebAssembly binary (missing \0asm header) to nie jest plik binarny WebAssembly (brak nagłówka \0asm) +@msg parameter `{k}`: {what} parametr `{k}`: {what} +@msg parameter `{name}`: {what} parametr `{name}`: {what} +@msg plug-in parameters must be a JSON object parametry wtyczki muszą być obiektem JSON +@msg the module must export its linear memory as `memory` moduł musi eksportować swoją pamięć liniową jako `memory` +@msg the objects to change are no longer in the document obiektów do zmiany nie ma już w dokumencie +@msg the runtime panicked środowisko uruchomieniowe uległo awarii +@msg unexpected host trap nieoczekiwane przerwanie (trap) hosta +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version zwróciła {v}; ten host implementuje wersję {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc zwróciła 0 (brak pamięci) +@msg vc_alloc returned a block outside memory vc_alloc zwróciła blok poza pamięcią +@msg vc_run returned error code {packed} vc_run zwróciła kod błędu {packed} +@msg {_1} is not a file {_1} nie jest plikiem +@msg {_1} is {_2} bytes (limit {_3}) {_1} ma {_2} bajtów (limit {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} obiektów (limit {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} wskazuje poza pamięć +@msg `angle` must be a number `angle` musi być liczbą +@msg `aspect` must be from 0.01 to 100 `aspect` musi mieścić się w zakresie od 0.01 do 100 +@msg `cap` must be "butt", "round" or "square" `cap` musi mieć wartość "butt", "round" lub "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` musi mieć wartość "nonzero" lub "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` musi być adresem https:// o długości co najwyżej {MAX_HELP_URL} bajtów +@msg `help` must be 1-{MAX_HELP} printable characters `help` musi mieć 1-{MAX_HELP} drukowalnych znaków +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` musi być identyfikatorem efektu o długości 1-{MAX_EFFECT_ID} bajtów +@msg `id` must be the id of an input object `id` musi być identyfikatorem obiektu wejściowego +@msg `join` must be "miter", "round" or "bevel" `join` musi mieć wartość "miter", "round" lub "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` i `group` muszą mieć 1-{MAX_LABEL} drukowalnych znaków +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` musi wynosić 1-{MAX_TEXT}, a wartość domyślna nie może być dłuższa +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` i `maxItems` muszą wynosić 0-{MAX_LIST}, `maxItems` co najmniej 1, a wartość domyślna musi mieć tyle elementów +@msg `name` must be a string `name` musi być ciągiem znaków +@msg `opacity` must be a number `opacity` musi być liczbą +@msg `type` must be "path" or "compound" `type` musi mieć wartość "path" lub "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` musi mieć 1-{MAX_UNIT} drukowalnych znaków +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` musi podawać od 1 do {MAX_CONDITION_VALUES} wartości dla `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` wskazuje `{other}`, który nie jest parametrem zadeklarowanym przed tym +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` wskazuje co najwyżej {MAX_CONDITIONS} parametrów +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` przyjmuje wartości `{other}`, czyli pola wyboru lub listy opcji +@msg `visible` must be true or false `visible` musi mieć wartość true lub false +@msg `{key}` must be a number from {_1} to {_2} `{key}` musi być liczbą od {_1} do {_2} +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` musi być tablicą o co najwyżej {MAX_ITEMS} elementach +@msg a color default must be a color object or a hex string such as "#3a7bd5" domyślny kolor musi być obiektem koloru lub ciągiem szesnastkowym, np. "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" obiektem koloru lub ciągiem szesnastkowym, np. "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines gradient dowolny zawiera co najwyżej {MAX_ITEMS} punktów i linii +@msg a freeform gradient line names a point it doesn't have linia gradientu dowolnego wskazuje punkt, którego gradient nie ma +@msg a gradient needs 1-{MAX_ITEMS} stops gradient wymaga 1-{MAX_ITEMS} punktów koloru +@msg a list of numbers listą liczb +@msg a list of {min_items} to {max_items} numbers listą od {min_items} do {max_items} liczb +@msg a new object needs a `path` nowy obiekt wymaga `path` +@msg a number liczbą +@msg a painted live effect returns only `objects` malujący efekt dynamiczny zwraca tylko `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` element malującego efektu dynamicznego ma tylko `path`, `fillRule`, `fills` i `strokes` +@msg a read-only object was left out pominięto obiekt tylko do odczytu +@msg a read-only object was left out: return it, as {"id"} alone or as it was given pominięto obiekt tylko do odczytu: zwróć go jako samo {"id"} lub w postaci, w jakiej został przekazany +@msg a string ciągiem znaków +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` obrys ma tylko `paint`, `width`, `join`, `cap` i `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} obrys musi mieć postać {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} efekt musi mieć postać {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` efekt wypełnienia zwraca tylko `fills` +@msg an effect on a stroke returns only `strokes` efekt obrysu zwraca tylko `strokes` +@msg an object must be a JSON object obiekt musi być obiektem JSON +@msg bad range nieprawidłowy zakres +@msg choices must be 1-64 non-empty, printable strings opcje muszą być 1-64 niepustymi, drukowalnymi ciągami znaków +@msg coordinate out of range (limit ±{MAX_COORD} pt) współrzędna spoza zakresu (limit ±{MAX_COORD} pt) +@msg default is not one of the options wartość domyślna nie jest jedną z opcji +@msg each piece needs a `path` każdy element wymaga `path` +@msg is listed twice występuje dwa razy +@msg pattern transform out of range przekształcenie wzorku spoza zakresu +@msg read-only field '{field}' changed zmieniono pole tylko do odczytu '{field}' +@msg stops must be in order of `offset` punkty koloru muszą być uporządkowane według `offset` +@msg the document has no pattern {pattern} dokument nie ma wzorku {pattern} +@msg the document has no swatch {n} dokument nie ma próbki {n} +@msg the output wyjście +@msg the path has no anchor points ścieżka nie ma punktów kontrolnych +@msg too many anchor points (limit {MAX_ANCHORS}) za dużo punktów kontrolnych (limit {MAX_ANCHORS}) +@msg true or false true lub false +@msg unknown key `{k}`; the keys here are {_1} nieznany klucz `{k}`; klucze w tym miejscu to {_1} +@msg {_1} is listed twice {_1} występuje dwa razy +@msg {_1} is not the id of an input object {_1} nie jest identyfikatorem obiektu wejściowego +@msg {_1} is read-only {_1} jest tylko do odczytu +@msg {what} must be from {lo} to {hi} {what} musi mieścić się w zakresie od {lo} do {hi} +@msg a color component składowa koloru +@msg a plate płyta # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Zainstalować „{name}”, wtyczkę, która prosi o te uprawnienia? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3ce111f7a4..5360d938a9 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6009,6 +6009,110 @@ axis Y Axis Eixo Y @msg a live effect runs while the document is drawn, so it can't be `heavy` um efeito dinâmico é executado enquanto o documento é desenhado, então não pode ser `heavy` @msg an exporter always gets an export's budget: leave `budget` out um plug-in de exportação sempre recebe o orçamento de uma exportação: omita `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” pede um orçamento de processamento grande (cada execução até {minutes} minutos e {times} vezes as instruções habituais): uma pessoa precisa aprová-lo no aplicativo de desktop, ou passe `allowHeavy: true` em uma sessão iniciada com --allow-plugin-grants +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` deve ter 1-64 caracteres imprimíveis +@msg `version`, `description` or `author` is too long `version`, `description` ou `author` é longo demais +@msg `warnings` must be a list of strings `warnings` deve ser uma lista de strings +@msg `{k}` must be one of {_1} `{k}` deve ser um de {_1} +@msg `{k}` must be {what} `{k}` deve ser {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` um plug-in de exportação declara de 1 a {MAX_EXTENSIONS} `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` no máximo {MAX_UNGROUPED} parâmetros fora de grupos: dê um `group` aos outros +@msg can't encode the input não é possível codificar a entrada +@msg expected {"dataBase64"} esperado {"dataBase64"} +@msg expected {"objects"} esperado {"objects"} +@msg export `{name}`: {e} exportação do módulo `{name}`: {e} +@msg extension {e} is listed twice a extensão {e} aparece duas vezes +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot extensão {e}: use 1-16 letras minúsculas e dígitos, sem o ponto +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - ID inválido {_1}: use 1-64 caracteres de A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} nome de parâmetro inválido {k} +@msg manifest is not UTF-8 o manifesto não está em UTF-8 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) o manifesto é longo demais (limite {MAX_MANIFEST_BYTES} bytes) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) o manifesto tem {_1} bytes (limite {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) o módulo tem {_1} bytes (limite {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own precisa de aprovação: pede {0}, que o usuário concede ao instalá-lo separadamente +@msg not JSON: {e} não é JSON: {e} +@msg not a WebAssembly binary (missing \0asm header) não é um binário WebAssembly (falta o cabeçalho \0asm) +@msg parameter `{k}`: {what} parâmetro `{k}`: {what} +@msg parameter `{name}`: {what} parâmetro `{name}`: {what} +@msg plug-in parameters must be a JSON object os parâmetros do plug-in devem ser um objeto JSON +@msg the module must export its linear memory as `memory` o módulo deve exportar sua memória linear como `memory` +@msg the objects to change are no longer in the document os objetos a alterar não estão mais no documento +@msg the runtime panicked o ambiente de execução falhou +@msg unexpected host trap trap inesperada do host +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version retornou {v}; este host implementa a versão {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc retornou 0 (sem memória) +@msg vc_alloc returned a block outside memory vc_alloc retornou um bloco fora da memória +@msg vc_run returned error code {packed} vc_run retornou o código de erro {packed} +@msg {_1} is not a file {_1} não é um arquivo +@msg {_1} is {_2} bytes (limit {_3}) {_1} tem {_2} bytes (limite {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} objetos (limite {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} aponta para fora da memória +@msg `angle` must be a number `angle` deve ser um número +@msg `aspect` must be from 0.01 to 100 `aspect` deve estar entre 0.01 e 100 +@msg `cap` must be "butt", "round" or "square" `cap` deve ser "butt", "round" ou "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` deve ser "nonzero" ou "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` deve ser um endereço https:// de no máximo {MAX_HELP_URL} bytes +@msg `help` must be 1-{MAX_HELP} printable characters `help` deve ter 1-{MAX_HELP} caracteres imprimíveis +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` deve ser um id de efeito de 1-{MAX_EFFECT_ID} bytes +@msg `id` must be the id of an input object `id` deve ser o id de um objeto de entrada +@msg `join` must be "miter", "round" or "bevel" `join` deve ser "miter", "round" ou "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` e `group` devem ter 1-{MAX_LABEL} caracteres imprimíveis +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` deve ser 1-{MAX_TEXT}, e o valor padrão não pode ser mais longo +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` e `maxItems` devem ser 0-{MAX_LIST}, `maxItems` pelo menos 1, e o valor padrão deve ter essa quantidade +@msg `name` must be a string `name` deve ser uma string +@msg `opacity` must be a number `opacity` deve ser um número +@msg `type` must be "path" or "compound" `type` deve ser "path" ou "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` deve ter 1-{MAX_UNIT} caracteres imprimíveis +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` deve listar de 1 a {MAX_CONDITION_VALUES} valores para `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` nomeia `{other}`, que não é um parâmetro declarado antes deste +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` nomeia no máximo {MAX_CONDITIONS} parâmetros +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` aceita os valores de `{other}`, de uma caixa de seleção ou de uma escolha +@msg `visible` must be true or false `visible` deve ser true ou false +@msg `{key}` must be a number from {_1} to {_2} `{key}` deve ser um número de {_1} a {_2} +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` deve ser uma matriz de no máximo {MAX_ITEMS} entradas +@msg a color default must be a color object or a hex string such as "#3a7bd5" uma cor padrão deve ser um objeto de cor ou uma string hexadecimal como "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" um objeto de cor ou uma string hexadecimal como "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines um gradiente de forma livre contém no máximo {MAX_ITEMS} pontos e linhas +@msg a freeform gradient line names a point it doesn't have uma linha do gradiente de forma livre nomeia um ponto que o gradiente não tem +@msg a gradient needs 1-{MAX_ITEMS} stops um gradiente precisa de 1-{MAX_ITEMS} paradas +@msg a list of numbers uma lista de números +@msg a list of {min_items} to {max_items} numbers uma lista de {min_items} a {max_items} números +@msg a new object needs a `path` um objeto novo precisa de um `path` +@msg a number um número +@msg a painted live effect returns only `objects` um efeito dinâmico de pintura retorna apenas `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` uma peça de um efeito dinâmico de pintura tem apenas `path`, `fillRule`, `fills` e `strokes` +@msg a read-only object was left out um objeto somente leitura foi omitido +@msg a read-only object was left out: return it, as {"id"} alone or as it was given um objeto somente leitura foi omitido: retorne-o como {"id"} sozinho ou como foi recebido +@msg a string uma string +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` um traçado tem apenas `paint`, `width`, `join`, `cap` e `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} um traçado deve ser {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} um efeito deve ser {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` um efeito em um preenchimento retorna apenas `fills` +@msg an effect on a stroke returns only `strokes` um efeito em um traçado retorna apenas `strokes` +@msg an object must be a JSON object um objeto deve ser um objeto JSON +@msg bad range intervalo inválido +@msg choices must be 1-64 non-empty, printable strings as opções devem ser 1-64 strings imprimíveis não vazias +@msg coordinate out of range (limit ±{MAX_COORD} pt) coordenada fora do intervalo (limite ±{MAX_COORD} pt) +@msg default is not one of the options o valor padrão não é uma das opções +@msg each piece needs a `path` cada peça precisa de um `path` +@msg is listed twice aparece duas vezes +@msg pattern transform out of range transformação do padrão fora do intervalo +@msg read-only field '{field}' changed o campo somente leitura '{field}' foi alterado +@msg stops must be in order of `offset` as paradas devem estar em ordem de `offset` +@msg the document has no pattern {pattern} o documento não tem o padrão {pattern} +@msg the document has no swatch {n} o documento não tem a amostra {n} +@msg the output a saída +@msg the path has no anchor points o caminho não tem pontos de ancoragem +@msg too many anchor points (limit {MAX_ANCHORS}) pontos de ancoragem demais (limite {MAX_ANCHORS}) +@msg true or false true ou false +@msg unknown key `{k}`; the keys here are {_1} chave desconhecida `{k}`; as chaves aqui são {_1} +@msg {_1} is listed twice {_1} aparece duas vezes +@msg {_1} is not the id of an input object {_1} não é o id de um objeto de entrada +@msg {_1} is read-only {_1} é somente leitura +@msg {what} must be from {lo} to {hi} {what} deve estar entre {lo} e {hi} +@msg a color component um componente de cor +@msg a plate uma placa # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Instalar “{name}”, que pede estas permissões? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c93807c08a..9b83cadb0b 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5705,6 +5705,110 @@ axis Y Axis Ось Y @msg a live effect runs while the document is drawn, so it can't be `heavy` динамический эффект выполняется во время отрисовки документа, поэтому он не может быть `heavy` @msg an exporter always gets an export's budget: leave `budget` out модуль экспорта всегда получает бюджет экспорта: не указывайте `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants «{_1}» запрашивает большой вычислительный бюджет (каждый запуск до {minutes} минут и в {times} раз больше инструкций, чем обычно): его должен одобрить человек в настольном приложении, либо передайте `allowHeavy: true` в сеансе, запущенном с --allow-plugin-grants +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` должен содержать 1-64 печатаемых символа +@msg `version`, `description` or `author` is too long `version`, `description` или `author` слишком длинное +@msg `warnings` must be a list of strings `warnings` должно быть списком строк +@msg `{k}` must be one of {_1} `{k}` должно быть одним из: {_1} +@msg `{k}` must be {what} `{k}` должно быть: {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` модуль экспорта объявляет от 1 до {MAX_EXTENSIONS} `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` вне групп может быть не более {MAX_UNGROUPED} параметров: задайте остальным `group` +@msg can't encode the input не удаётся закодировать входные данные +@msg expected {"dataBase64"} ожидалось {"dataBase64"} +@msg expected {"objects"} ожидалось {"objects"} +@msg export `{name}`: {e} экспорт модуля `{name}`: {e} +@msg extension {e} is listed twice расширение {e} указано дважды +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot расширение {e}: используйте 1-16 строчных букв и цифр, без точки +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - недопустимый ID {_1}: используйте 1-64 символа из A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} недопустимое имя параметра {k} +@msg manifest is not UTF-8 манифест не в кодировке UTF-8 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) манифест слишком длинный (предел {MAX_MANIFEST_BYTES} байт) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) манифест занимает {_1} байт (предел {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) модуль занимает {_1} байт (предел {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own требуется одобрение: он запрашивает {0}, что пользователь разрешает при отдельной установке +@msg not JSON: {e} не JSON: {e} +@msg not a WebAssembly binary (missing \0asm header) не двоичный файл WebAssembly (нет заголовка \0asm) +@msg parameter `{k}`: {what} параметр `{k}`: {what} +@msg parameter `{name}`: {what} параметр `{name}`: {what} +@msg plug-in parameters must be a JSON object параметры модуля должны быть объектом JSON +@msg the module must export its linear memory as `memory` модуль должен экспортировать свою линейную память как `memory` +@msg the objects to change are no longer in the document изменяемых объектов больше нет в документе +@msg the runtime panicked среда выполнения аварийно завершилась +@msg unexpected host trap непредвиденная ловушка (trap) хоста +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version вернула {v}; этот хост реализует версию {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc вернула 0 (нехватка памяти) +@msg vc_alloc returned a block outside memory vc_alloc вернула блок вне памяти +@msg vc_run returned error code {packed} vc_run вернула код ошибки {packed} +@msg {_1} is not a file {_1} не является файлом +@msg {_1} is {_2} bytes (limit {_3}) {_1} занимает {_2} байт (предел {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) объектов: {_1} (предел {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} указывает за пределы памяти +@msg `angle` must be a number `angle` должно быть числом +@msg `aspect` must be from 0.01 to 100 `aspect` должно быть от 0.01 до 100 +@msg `cap` must be "butt", "round" or "square" `cap` должно быть "butt", "round" или "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` должно быть "nonzero" или "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` должно быть адресом https:// длиной не более {MAX_HELP_URL} байт +@msg `help` must be 1-{MAX_HELP} printable characters `help` должно содержать 1-{MAX_HELP} печатаемых символов +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` должно быть идентификатором эффекта длиной 1-{MAX_EFFECT_ID} байт +@msg `id` must be the id of an input object `id` должно быть идентификатором входного объекта +@msg `join` must be "miter", "round" or "bevel" `join` должно быть "miter", "round" или "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` и `group` должны содержать 1-{MAX_LABEL} печатаемых символов +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` должно быть 1-{MAX_TEXT}, а значение по умолчанию не длиннее +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` и `maxItems` должны быть 0-{MAX_LIST}, `maxItems` не меньше 1, а значение по умолчанию должно содержать столько элементов +@msg `name` must be a string `name` должно быть строкой +@msg `opacity` must be a number `opacity` должно быть числом +@msg `type` must be "path" or "compound" `type` должно быть "path" или "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` должно содержать 1-{MAX_UNIT} печатаемых символов +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` должно перечислять от 1 до {MAX_CONDITION_VALUES} значений для `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` указывает `{other}`, но это не параметр, объявленный раньше этого +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` указывает не более {MAX_CONDITIONS} параметров +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` принимает значения `{other}`, флажка или списка вариантов +@msg `visible` must be true or false `visible` должно быть true или false +@msg `{key}` must be a number from {_1} to {_2} `{key}` должно быть числом от {_1} до {_2} +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` должно быть массивом не более чем из {MAX_ITEMS} элементов +@msg a color default must be a color object or a hex string such as "#3a7bd5" цвет по умолчанию должен быть объектом цвета или шестнадцатеричной строкой, например "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" объект цвета или шестнадцатеричная строка, например "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines произвольный градиент содержит не более {MAX_ITEMS} точек и линий +@msg a freeform gradient line names a point it doesn't have линия произвольного градиента указывает точку, которой в градиенте нет +@msg a gradient needs 1-{MAX_ITEMS} stops градиенту нужно 1-{MAX_ITEMS} точек перехода +@msg a list of numbers список чисел +@msg a list of {min_items} to {max_items} numbers список из {min_items}-{max_items} чисел +@msg a new object needs a `path` новому объекту нужен `path` +@msg a number число +@msg a painted live effect returns only `objects` рисующий динамический эффект возвращает только `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` у части рисующего динамического эффекта есть только `path`, `fillRule`, `fills` и `strokes` +@msg a read-only object was left out пропущен объект только для чтения +@msg a read-only object was left out: return it, as {"id"} alone or as it was given пропущен объект только для чтения: верните его как один {"id"} или в том виде, в каком он был передан +@msg a string строка +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` у обводки есть только `paint`, `width`, `join`, `cap` и `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} обводка должна иметь вид {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} эффект должен иметь вид {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` эффект на заливке возвращает только `fills` +@msg an effect on a stroke returns only `strokes` эффект на обводке возвращает только `strokes` +@msg an object must be a JSON object объект должен быть объектом JSON +@msg bad range недопустимый диапазон +@msg choices must be 1-64 non-empty, printable strings варианты должны быть 1-64 непустыми печатаемыми строками +@msg coordinate out of range (limit ±{MAX_COORD} pt) координата вне диапазона (предел ±{MAX_COORD} pt) +@msg default is not one of the options значение по умолчанию не входит в варианты +@msg each piece needs a `path` каждой части нужен `path` +@msg is listed twice указан дважды +@msg pattern transform out of range преобразование узора вне диапазона +@msg read-only field '{field}' changed изменено поле только для чтения '{field}' +@msg stops must be in order of `offset` точки перехода должны идти в порядке `offset` +@msg the document has no pattern {pattern} в документе нет узора {pattern} +@msg the document has no swatch {n} в документе нет образца {n} +@msg the output вывод +@msg the path has no anchor points у контура нет опорных точек +@msg too many anchor points (limit {MAX_ANCHORS}) слишком много опорных точек (предел {MAX_ANCHORS}) +@msg true or false true или false +@msg unknown key `{k}`; the keys here are {_1} неизвестный ключ `{k}`; здесь допустимы ключи {_1} +@msg {_1} is listed twice {_1} указан дважды +@msg {_1} is not the id of an input object {_1} не является идентификатором входного объекта +@msg {_1} is read-only {_1} доступен только для чтения +@msg {what} must be from {lo} to {hi} {what}: значение должно быть от {lo} до {hi} +@msg a color component компонент цвета +@msg a plate пластина # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Установить «{name}», которому нужны эти разрешения? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ce20b6c72d..7bb05a80b2 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6116,6 +6116,74 @@ axis Y Axis Y-axel @msg a live effect runs while the document is drawn, so it can't be `heavy` en live-effekt körs medan dokumentet ritas, så den kan inte vara `heavy` @msg an exporter always gets an export's budget: leave `budget` out ett exportinsticksprogram får alltid en exports budget: utelämna `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants ”{_1}” begär en stor beräkningsbudget (varje körning upp till {minutes} minuter och {times} gånger de vanliga instruktionerna): en person måste godkänna det i skrivbordsappen, eller skicka `allowHeavy: true` i en session som startats med --allow-plugin-grants +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `{k}` must be one of {_1} `{k}` måste vara något av {_1} +@msg needs approval: it asks for {0}, which the user grants when installing it on its own kräver godkännande: det begär {0}, som användaren beviljar när pluginet installeras för sig +@msg `angle` must be a number `angle` måste vara ett tal +@msg `aspect` must be from 0.01 to 100 `aspect` måste vara från 0.01 till 100 +@msg `cap` must be "butt", "round" or "square" `cap` måste vara "butt", "round" eller "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` måste vara "nonzero" eller "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` måste vara en https://-adress på högst {MAX_HELP_URL} byte +@msg `help` must be 1-{MAX_HELP} printable characters `help` måste ha 1-{MAX_HELP} utskrivbara tecken +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` måste vara ett effekt-id på 1-{MAX_EFFECT_ID} byte +@msg `id` must be the id of an input object `id` måste vara id:t för ett indataobjekt +@msg `join` must be "miter", "round" or "bevel" `join` måste vara "miter", "round" eller "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` och `group` måste ha 1-{MAX_LABEL} utskrivbara tecken +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` måste vara 1-{MAX_TEXT}, och standardvärdet får inte vara längre +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` och `maxItems` måste vara 0-{MAX_LIST}, `maxItems` minst 1, och standardvärdet måste ha så många poster +@msg `name` must be a string `name` måste vara en sträng +@msg `opacity` must be a number `opacity` måste vara ett tal +@msg `type` must be "path" or "compound" `type` måste vara "path" eller "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` måste ha 1-{MAX_UNIT} utskrivbara tecken +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` måste ange 1 till {MAX_CONDITION_VALUES} värden för `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` anger `{other}`, som inte är en parameter som deklarerats före den här +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` anger högst {MAX_CONDITIONS} parametrar +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` tar värden från `{other}`, en kryssruta eller ett val +@msg `visible` must be true or false `visible` måste vara true eller false +@msg `{key}` must be a number from {_1} to {_2} `{key}` måste vara ett tal från {_1} till {_2} +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` måste vara en array med högst {MAX_ITEMS} poster +@msg a color default must be a color object or a hex string such as "#3a7bd5" ett standardvärde för färg måste vara ett färgobjekt eller en hexsträng som "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" ett färgobjekt eller en hexsträng som "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines en fri gradient innehåller högst {MAX_ITEMS} punkter och linjer +@msg a freeform gradient line names a point it doesn't have en linje i en fri gradient anger en punkt som gradienten inte har +@msg a gradient needs 1-{MAX_ITEMS} stops en gradient behöver 1-{MAX_ITEMS} stopp +@msg a list of numbers en lista med tal +@msg a list of {min_items} to {max_items} numbers en lista med {min_items} till {max_items} tal +@msg a new object needs a `path` ett nytt objekt behöver en `path` +@msg a number ett tal +@msg a painted live effect returns only `objects` en målande live-effekt returnerar bara `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` en del av en målande live-effekt har bara `path`, `fillRule`, `fills` och `strokes` +@msg a read-only object was left out ett skrivskyddat objekt utelämnades +@msg a read-only object was left out: return it, as {"id"} alone or as it was given ett skrivskyddat objekt utelämnades: returnera det som enbart {"id"} eller som det gavs +@msg a string en sträng +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` en kontur har bara `paint`, `width`, `join`, `cap` och `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} en kontur måste vara {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} en effekt måste vara {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` en effekt på en fyllning returnerar bara `fills` +@msg an effect on a stroke returns only `strokes` en effekt på en kontur returnerar bara `strokes` +@msg an object must be a JSON object ett objekt måste vara ett JSON-objekt +@msg bad range ogiltigt intervall +@msg choices must be 1-64 non-empty, printable strings valen måste vara 1-64 icke-tomma, utskrivbara strängar +@msg coordinate out of range (limit ±{MAX_COORD} pt) koordinat utanför intervallet (gräns ±{MAX_COORD} pt) +@msg default is not one of the options standardvärdet är inte ett av alternativen +@msg each piece needs a `path` varje del behöver en `path` +@msg is listed twice finns två gånger +@msg pattern transform out of range mönstertransformering utanför intervallet +@msg read-only field '{field}' changed det skrivskyddade fältet '{field}' ändrades +@msg stops must be in order of `offset` stoppen måste vara ordnade efter `offset` +@msg the document has no pattern {pattern} dokumentet har inget mönster {pattern} +@msg the document has no swatch {n} dokumentet har ingen färgruta {n} +@msg the output utdata +@msg the path has no anchor points banan har inga ankarpunkter +@msg too many anchor points (limit {MAX_ANCHORS}) för många ankarpunkter (gräns {MAX_ANCHORS}) +@msg true or false true eller false +@msg unknown key `{k}`; the keys here are {_1} okänd nyckel `{k}`; nycklarna här är {_1} +@msg {_1} is listed twice {_1} finns två gånger +@msg {_1} is not the id of an input object {_1} är inte id:t för ett indataobjekt +@msg {_1} is read-only {_1} är skrivskyddat +@msg {what} must be from {lo} to {hi} {what} måste vara från {lo} till {hi} +@msg a color component en färgkomponent +@msg a plate en plåt # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Installera ”{name}”, som begär dessa behörigheter? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index ade1efa8be..28e4e407bd 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1718,6 +1718,21 @@ fn package_status_message_templates_parse() { assert!(Catalog::parse("@msg\tPackaged {n} file(s) in {folder}{note}\tbad\n").message("Packaged 1 file(s) in /a b").is_none()); } +/// A template that starts with text matches a message it wraps before a template that starts +/// with a placeholder, even one with more literal text, so the wrapped reason is translated by +/// its own row. +#[test] +fn templates_that_start_with_text_match_first() { + let c = Catalog::parse(concat!( + "@msg\tplug-in manifest: {0}\twrapped {0}\n", + "@msg\t{_1} is {_2} bytes (limit {_3})\tsized {_1}\n", + "@msg\tmanifest is {_1} bytes (limit {_2})\tmanifest {_1}\n", + )); + let (form, caps) = c.message("plug-in manifest: manifest is 9 bytes (limit 8)").expect("matches"); + assert_eq!((form, caps), ("wrapped {0}", vec![("0", "manifest is 9 bytes (limit 8)")])); + assert_eq!(c.message("a.wasm is 9 bytes (limit 8)").map(|(f, _)| f), Some("sized {_1}")); +} + /// A `@msgplural` row translates both English forms of a message with a count, and the count /// picks the language's form. #[test] @@ -1748,6 +1763,86 @@ fn warped_type_note_is_translated() { } } +/// The plug-in host's status and error messages (manifests, modules, runs and the output of +/// importers, exporters and effects) have an `@msg` row in every complete-message catalog. +#[test] +fn plugin_messages_are_translated_in_every_complete_catalog() { + let messages = message_literals_in(&["plugins"]); + assert!(messages.len() > 100, "scan found only {} plug-in messages", messages.len()); + let mut missing = vec![]; + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + let (entries, _) = parse_entries(lang.0.source); + let rows: std::collections::BTreeSet<&str> = entries.iter().filter(|(ctx, _, _)| ctx == "@msg").map(|(_, src, _)| src.as_str()).collect(); + missing.extend(messages.iter().filter(|m| !rows.contains(m.as_str())).map(|m| format!("{code}: {m}"))); + } + assert!(missing.is_empty(), "{} plug-in messages have no @msg row:\n{}", missing.len(), missing.join("\n")); +} + +/// Errors the plug-in host returns are translated whole in every complete-message catalog, the +/// reason inside the error's kind included, and keep their numbers, JSON shapes, permission names +/// and nested reasons as written. +#[test] +fn plugin_errors_translate_with_their_values() { + use vectorcraft_plugins::{Error, Manifest, manifest::MAX_MANIFEST_BYTES}; + let err = |r: Result<(), Error>| r.err().map(|e| e.to_string()).unwrap_or_default(); + let not_json = err(vectorcraft_plugins::export::decode(b"{} x").map(drop)); + let reason = not_json.strip_prefix("plug-in output: not JSON: ").expect("not JSON error").to_string(); + let errors = [ + (err(Manifest::parse(b"\xff").map(drop)), "manifest is not UTF-8", vec!["UTF-8"]), + (err(Manifest::parse(&vec![b' '; MAX_MANIFEST_BYTES + 1]).map(drop)), "manifest is", vec!["65537", "65536"]), + (err(vectorcraft_plugins::export::decode(b"{}").map(drop)), "expected {", vec![r#"{"dataBase64": "…"}"#]), + (err(vectorcraft_plugins::objects::decode(b"{}").map(drop)), "expected {", vec![r#"{"objects": [...]}"#]), + (not_json, "not JSON", vec![reason.as_str()]), + (Error::NeedsApproval("doc.read, doc.write".into()).to_string(), "needs approval", vec!["doc.read, doc.write"]), + ]; + for (e, english, _) in &errors { + assert!(e.contains(english), "{e}"); + } + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + for (e, english, kept) in &errors { + let shown = message(lang, e); + assert!(!shown.contains(english), "{code}: {e} -> {shown}"); + assert!(kept.iter().all(|k| shown.contains(k)), "{code}: {e} -> {shown}"); + } + } + assert_eq!(message(de(), &errors[1].0), "Plug-in-Manifest: das Manifest ist 65537 Byte groß (Grenze 65536)"); + let fr = Lang::from_code("fr").expect("registered"); + assert!(message(fr, &errors[3].0).ends_with(r#"attendu : {"objects": [...]}"#), "{}", message(fr, &errors[3].0)); +} + +/// The reasons the plug-in host puts inside its errors (what is wrong with a parameter's spec or +/// dialog keys, the value a parameter needs, what is wrong with an output object) are translated +/// with the error in every complete-message catalog, so no status line mixes two languages. +#[test] +fn plugin_error_reasons_are_translated_with_the_error() { + use serde_json::json; + use vectorcraft_plugins::{Error, Manifest}; + let err = |r: Result<(), Error>| r.err().map(|e| e.to_string()).unwrap_or_default(); + let manifest = |params: &str| Manifest::parse(format!(r#"{{"id":"a","name":"x","kind":"filter","params":{{{params}}}}}"#).as_bytes()); + let m = manifest(r#""angles":{"type":"numberList","min":0,"max":360,"default":[45],"minItems":1,"maxItems":3}"#).expect("manifest"); + let errors = [ + (err(manifest(r#""r":{"type":"number","min":5,"max":1,"default":1}"#).map(drop)), "bad range"), + (err(manifest(r#""r":{"type":"number","min":0,"max":1,"default":1,"label":" "}"#).map(drop)), "printable characters"), + (err(m.resolve_params(&json!({"angles": "big"})).map(drop)), "a list of numbers"), + (err(vectorcraft_plugins::objects::decode(br#"{"objects":[{"opacity":"full"}]}"#).map(drop)), "must be a number"), + ]; + for (e, english) in &errors { + assert!(e.contains(english), "{e}"); + } + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + for (e, english) in &errors { + let shown = message(lang, e); + assert!(!shown.contains(english), "{code}: {e} -> {shown}"); + } + } + assert_eq!(message(de(), &errors[0].0), "Plug-in-Manifest: Parameter `r`: ungültiger Bereich"); + assert_eq!(message(de(), &errors[2].0), "Plug-in-Parameter: `angles` muss eine Liste von Zahlen sein"); + assert!(message(de(), &errors[3].0).ends_with("objects[0]: `opacity` muss eine Zahl sein"), "{}", message(de(), &errors[3].0)); +} + /// The Gamut Check status line names a count of 1 in the singular and other counts in the plural, /// and every complete catalog translates it, in the form its plural rule picks. #[test] @@ -2003,6 +2098,53 @@ fn run() -> Result<()> { assert!(source_messages(code, &[], &Default::default()).is_empty()); } +/// The scan reads the message of each of the plug-in host's [`ERROR_HELPERS`]: a literal, a +/// `format!` string with or without a `&` before it, after the location of `out_err` (a +/// `format!` string too), the number and range of `in_range`, the number and location of `unit`, +/// and the block of `.read_packed`, a method. It does not read a value that isn't a literal, a +/// key's name alone, a call with too few arguments, a helper's declaration or a call whose name +/// ends in a helper's name. +#[test] +fn message_scan_reads_plugin_helpers() { + let code = r#" +fn check(k: &str) -> Result<()> { + let bad = |what: &str| Err(Error::Manifest(format!("parameter `{k}`: {what}"))); + let wrong = |what: &str| Error::Params(format!("`{k}` must be {what}")); + let _ = bad("bad range"); + let _ = bad(&format!("`maxLength` must be 1-{MAX_TEXT}, and the default no longer")); + let _ = wrong("a number"); + let _ = wrong(&format!("a list of {min_items} to {max_items} numbers")); + let _ = out_err(at, "`opacity` must be a number"); + let _ = out_err(&format!("{at}.{k}"), format!("unknown key `{k}`; the keys here are {}", keys.join(", "))); + let _ = out_err(at, e); + let _ = live.read_packed(packed, "the output", limits.max_output_bytes); + let _ = in_range(l, 0.0, 100.0, at, "a color component"); + let _ = unit(*x, &format!("{at}.plates"), "a plate"); + let _ = unit(s.tint, &format!("{at}.tint"), "`tint`"); + let _ = unit(r); + let _ = not_bad("not an error helper"); + Err(wrong()) +} +fn wrong(&self) -> String { String::new() } +"#; + let plugins: Vec<_> = ERROR_HELPERS.iter().filter(|(c, _, _)| *c == "plugins").map(|&(_, call, before)| (call, before)).collect(); + let found: std::collections::BTreeSet = source_messages(code, &plugins, &Default::default()).into_iter().collect(); + let want = [ + "`maxLength` must be 1-{MAX_TEXT}, and the default no longer", + "`opacity` must be a number", + "`{k}` must be {what}", + "a color component", + "a list of {min_items} to {max_items} numbers", + "a number", + "a plate", + "bad range", + "parameter `{k}`: {what}", + "the output", + "unknown key `{k}`; the keys here are {_1}", + ]; + assert_eq!(found.iter().collect::>(), want); +} + /// The limits a plug-in run can exceed, and the error of a removal that leaves the plug-in's grant /// in the plug-in store, are translated in every language that translates every message, as the /// status bar shows them. @@ -2747,10 +2889,25 @@ const MESSAGE_MARKERS: &[&str] = &[".status(", "ui.status = ", "status = ", "Oth /// Helpers that make an error from a message, by crate, with the number of arguments before the /// message: the engine's `bad(cmd, msg)` (`EngineError::BadParams`), `err(msg)` (`cmd/fileio`) -/// and `error(msg)` (`cmd/dimension.rs`), and the PDF writer's `bad(why)` (`forms.rs`) and -/// `failed(why)` (`encrypt.rs`, `linearize.rs`). -const ERROR_HELPERS: &[(&str, &str, usize)] = - &[("engine", "bad(", 1), ("engine", "err(", 0), ("engine", "error(", 0), ("pdf", "bad(", 0), ("pdf", "failed(", 0)]; +/// and `error(msg)` (`cmd/dimension.rs`), the PDF writer's `bad(why)` (`forms.rs`) and +/// `failed(why)` (`encrypt.rs`, `linearize.rs`), and the plug-in host's `bad(what)` (a parameter's +/// spec and dialog keys), `wrong(what)` (a parameter's value), `out_err(at, msg)` (plug-in output), +/// `in_range(x, lo, hi, at, what)` and `unit(x, at, what)` (a painted effect's numbers) and +/// `.read_packed(packed, what, max)` (the block a plug-in returned). A helper that starts with +/// `.` is a method. +const ERROR_HELPERS: &[(&str, &str, usize)] = &[ + ("engine", "bad(", 1), + ("engine", "err(", 0), + ("engine", "error(", 0), + ("pdf", "bad(", 0), + ("pdf", "failed(", 0), + ("plugins", "bad(", 0), + ("plugins", "wrong(", 0), + ("plugins", "out_err(", 1), + ("plugins", "in_range(", 4), + ("plugins", "unit(", 2), + ("plugins", ".read_packed(", 1), +]; /// Error variants whose first argument is a name, not a message: a command id /// (`EngineError::UnknownCommand`) and a PostScript error name (`PsError::Ps`). @@ -2760,16 +2917,22 @@ const NAME_VARIANTS: &[&str] = &["UnknownCommand", "Ps"]; /// `EngineError::Disabled(command id, reason)`. const LATER_MESSAGE_VARIANTS: &[(&str, usize)] = &[("Disabled", 1)]; -/// Messages that are signals, not text for people. -const NOT_MESSAGES: &[&str] = &["quit", "cancelled"]; +/// Messages that are signals, not text for people, and the name of a plug-in export that an +/// error message holds (`vc_manifest points outside memory`). +const NOT_MESSAGES: &[&str] = &["quit", "cancelled", "vc_manifest"]; /// Every status and error message literal in [`MESSAGE_CRATES`] (test modules aside), as catalog /// templates: `{}` becomes `{_1}`, `{_2}` … and format specs are dropped (`{n:?}` → `{n}`). pub fn message_literals() -> std::collections::BTreeSet { + message_literals_in(MESSAGE_CRATES) +} + +/// The message literals of [`message_literals`] in the crates `names` alone. +fn message_literals_in(names: &[&str]) -> std::collections::BTreeSet { let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); let mut out = std::collections::BTreeSet::new(); let mut files = vec![]; - let mut stack: Vec<_> = MESSAGE_CRATES.iter().map(|c| crates.join(c).join("src")).collect(); + let mut stack: Vec<_> = names.iter().map(|c| crates.join(c).join("src")).collect(); while let Some(d) = stack.pop() { for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() { let path = entry.path(); @@ -2835,7 +2998,8 @@ fn named_const<'a>(rest: &str, consts: &'a std::collections::BTreeMap>) -> Vec { let code = &without_comments(code); let after_markers = MESSAGE_MARKERS.iter().flat_map(|marker| code.match_indices(marker).map(|(at, _)| &code[at + marker.len()..])); @@ -2848,12 +3012,13 @@ fn source_messages(code: &str, helpers: &[(&str, usize)], consts: &std::collecti }); let in_helpers = helpers.iter().flat_map(|&(call, before)| { code.match_indices(call) - .filter(|&(at, _)| !code[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_' || c == '.')) + .filter(|&(at, _)| call.starts_with('.') || !code[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_' || c == '.')) .filter_map(move |(at, _)| skip_args(&code[at + call.len()..], before)) }); let mut out = vec![]; for rest in after_markers.chain(in_variants).chain(in_helpers) { let rest = rest.trim_start(); + let rest = rest.strip_prefix('&').map_or(rest, str::trim_start); let rest = rest.strip_prefix("format!(").map_or(rest, str::trim_start); let lits = match rest.strip_prefix('"') { Some(quoted) => string_literal(quoted).into_iter().collect(), @@ -2861,7 +3026,8 @@ fn source_messages(code: &str, helpers: &[(&str, usize)], consts: &std::collecti }; for lit in lits { let has_word = lit.as_bytes().windows(3).any(|w| w.iter().all(u8::is_ascii_lowercase)); - if has_word && !NOT_MESSAGES.contains(&lit.as_str()) { + let key_name = lit.len() > 1 && lit.starts_with('`') && lit.ends_with('`') && !lit.contains(' '); + if has_word && !key_name && !NOT_MESSAGES.contains(&lit.as_str()) { out.push(as_template(&lit)); } } diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index db3c4501b7..5abc7569e6 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6050,6 +6050,110 @@ axis Y Axis Y ekseni @msg a live effect runs while the document is drawn, so it can't be `heavy` canlı efekt belge çizilirken çalışır, bu yüzden `heavy` olamaz @msg an exporter always gets an export's budget: leave `budget` out dışa aktarma eklentisi her zaman bir dışa aktarmanın bütçesini alır: `budget` anahtarını çıkarın @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” büyük bir hesaplama bütçesi istiyor (her çalıştırma en fazla {minutes} dakika ve olağan komutların {times} katı): masaüstü uygulamasında bir kişinin onaylaması gerekir veya --allow-plugin-grants ile başlatılan bir oturumda `allowHeavy: true` geçirin +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` 1-64 yazdırılabilir karakter olmalıdır +@msg `version`, `description` or `author` is too long `version`, `description` veya `author` çok uzun +@msg `warnings` must be a list of strings `warnings` bir dize listesi olmalıdır +@msg `{k}` must be one of {_1} `{k}` şunlardan biri olmalıdır: {_1} +@msg `{k}` must be {what} `{k}` şu olmalıdır: {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` dışa aktarma eklentisi 1 ile {MAX_EXTENSIONS} arasında `extensions` bildirir +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` gruplar dışında en fazla {MAX_UNGROUPED} parametre olabilir: diğerlerine bir `group` verin +@msg can't encode the input girdi kodlanamıyor +@msg expected {"dataBase64"} beklenen: {"dataBase64"} +@msg expected {"objects"} beklenen: {"objects"} +@msg export `{name}`: {e} modül dışa aktarımı `{name}`: {e} +@msg extension {e} is listed twice {e} uzantısı iki kez listelenmiş +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot {e} uzantısı: nokta olmadan 1-16 küçük harf ve rakam kullanın +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - geçersiz kimlik {_1}: A-Z a-z 0-9 . _ - arasından 1-64 karakter kullanın +@msg invalid parameter name {k} geçersiz parametre adı {k} +@msg manifest is not UTF-8 bildirim UTF-8 değil +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) bildirim çok uzun (sınır {MAX_MANIFEST_BYTES} bayt) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) bildirim {_1} bayt (sınır {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) modül {_1} bayt (sınır {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own onay gerekiyor: {0} istiyor; kullanıcı bunu eklentiyi tek başına yüklerken verir +@msg not JSON: {e} JSON değil: {e} +@msg not a WebAssembly binary (missing \0asm header) WebAssembly ikili dosyası değil (\0asm üst bilgisi eksik) +@msg parameter `{k}`: {what} `{k}` parametresi: {what} +@msg parameter `{name}`: {what} `{name}` parametresi: {what} +@msg plug-in parameters must be a JSON object eklenti parametreleri bir JSON nesnesi olmalıdır +@msg the module must export its linear memory as `memory` modül doğrusal belleğini `memory` olarak dışa aktarmalıdır +@msg the objects to change are no longer in the document değiştirilecek nesneler artık belgede değil +@msg the runtime panicked çalışma zamanı çöktü +@msg unexpected host trap ana bilgisayarda beklenmeyen kesme (trap) +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version {v} döndürdü; bu ana bilgisayar {ABI_VERSION} sürümünü uygular +@msg vc_alloc returned 0 (out of memory) vc_alloc 0 döndürdü (bellek yetersiz) +@msg vc_alloc returned a block outside memory vc_alloc bellek dışında bir blok döndürdü +@msg vc_run returned error code {packed} vc_run {packed} hata kodunu döndürdü +@msg {_1} is not a file {_1} bir dosya değil +@msg {_1} is {_2} bytes (limit {_3}) {_1} {_2} bayt (sınır {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} nesne (sınır {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} bellek dışını gösteriyor +@msg `angle` must be a number `angle` bir sayı olmalıdır +@msg `aspect` must be from 0.01 to 100 `aspect` 0.01 ile 100 arasında olmalıdır +@msg `cap` must be "butt", "round" or "square" `cap` "butt", "round" veya "square" olmalıdır +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` "nonzero" veya "evenodd" olmalıdır +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` en fazla {MAX_HELP_URL} baytlık bir https:// adresi olmalıdır +@msg `help` must be 1-{MAX_HELP} printable characters `help` 1-{MAX_HELP} yazdırılabilir karakter olmalıdır +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` 1-{MAX_EFFECT_ID} baytlık bir efekt kimliği olmalıdır +@msg `id` must be the id of an input object `id` bir girdi nesnesinin kimliği olmalıdır +@msg `join` must be "miter", "round" or "bevel" `join` "miter", "round" veya "bevel" olmalıdır +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` ve `group` 1-{MAX_LABEL} yazdırılabilir karakter olmalıdır +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` 1-{MAX_TEXT} olmalı ve varsayılan değer bundan uzun olmamalıdır +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` ve `maxItems` 0-{MAX_LIST} olmalı, `maxItems` en az 1 olmalı ve varsayılan değer bu kadar öğe içermelidir +@msg `name` must be a string `name` bir dize olmalıdır +@msg `opacity` must be a number `opacity` bir sayı olmalıdır +@msg `type` must be "path" or "compound" `type` "path" veya "compound" olmalıdır +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` 1-{MAX_UNIT} yazdırılabilir karakter olmalıdır +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen`, `{other}` için 1 ile {MAX_CONDITION_VALUES} arasında değer listelemelidir +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` `{other}` adını veriyor, ancak bu, bundan önce bildirilmiş bir parametre değil +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` en fazla {MAX_CONDITIONS} parametre adı verir +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen`, bir onay kutusu veya seçim olan `{other}` parametresinin değerlerini alır +@msg `visible` must be true or false `visible` true veya false olmalıdır +@msg `{key}` must be a number from {_1} to {_2} `{key}` {_1} ile {_2} arasında bir sayı olmalıdır +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` en fazla {MAX_ITEMS} öğeli bir dizi olmalıdır +@msg a color default must be a color object or a hex string such as "#3a7bd5" varsayılan renk bir renk nesnesi veya "#3a7bd5" gibi onaltılık bir dize olmalıdır +@msg a color object or a hex string such as "#3a7bd5" bir renk nesnesi veya "#3a7bd5" gibi onaltılık bir dize +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines serbest biçimli degrade en fazla {MAX_ITEMS} nokta ve çizgi içerir +@msg a freeform gradient line names a point it doesn't have serbest biçimli degradenin bir çizgisi, degradede olmayan bir noktayı belirtiyor +@msg a gradient needs 1-{MAX_ITEMS} stops bir degradenin 1-{MAX_ITEMS} durağa ihtiyacı vardır +@msg a list of numbers bir sayı listesi +@msg a list of {min_items} to {max_items} numbers {min_items} ile {max_items} arasında sayıdan oluşan bir liste +@msg a new object needs a `path` yeni bir nesnenin `path` değerine ihtiyacı vardır +@msg a number bir sayı +@msg a painted live effect returns only `objects` boyama türü canlı efekt yalnızca `objects` döndürür +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` boyama türü canlı efektin bir parçasında yalnızca `path`, `fillRule`, `fills` ve `strokes` bulunur +@msg a read-only object was left out salt okunur bir nesne atlandı +@msg a read-only object was left out: return it, as {"id"} alone or as it was given salt okunur bir nesne atlandı: onu yalnızca {"id"} olarak veya verildiği gibi döndürün +@msg a string bir dize +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` bir konturda yalnızca `paint`, `width`, `join`, `cap` ve `miterLimit` bulunur +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} bir kontur {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} biçiminde olmalıdır +@msg an effect must be {"id", "params"?, "visible"?} bir efekt {"id", "params"?, "visible"?} biçiminde olmalıdır +@msg an effect on a fill returns only `fills` dolgudaki bir efekt yalnızca `fills` döndürür +@msg an effect on a stroke returns only `strokes` konturdaki bir efekt yalnızca `strokes` döndürür +@msg an object must be a JSON object bir nesne bir JSON nesnesi olmalıdır +@msg bad range geçersiz aralık +@msg choices must be 1-64 non-empty, printable strings seçenekler 1-64 boş olmayan, yazdırılabilir dize olmalıdır +@msg coordinate out of range (limit ±{MAX_COORD} pt) koordinat aralık dışında (sınır ±{MAX_COORD} pt) +@msg default is not one of the options varsayılan değer seçeneklerden biri değil +@msg each piece needs a `path` her parçanın `path` değerine ihtiyacı vardır +@msg is listed twice iki kez listelenmiş +@msg pattern transform out of range desen dönüşümü aralık dışında +@msg read-only field '{field}' changed salt okunur '{field}' alanı değişti +@msg stops must be in order of `offset` duraklar `offset` sırasında olmalıdır +@msg the document has no pattern {pattern} belgede {pattern} deseni yok +@msg the document has no swatch {n} belgede {n} renk örneği yok +@msg the output çıktı +@msg the path has no anchor points yolun bağlantı noktası yok +@msg too many anchor points (limit {MAX_ANCHORS}) çok fazla bağlantı noktası (sınır {MAX_ANCHORS}) +@msg true or false true veya false +@msg unknown key `{k}`; the keys here are {_1} bilinmeyen anahtar `{k}`; buradaki anahtarlar: {_1} +@msg {_1} is listed twice {_1} iki kez listelenmiş +@msg {_1} is not the id of an input object {_1} bir girdi nesnesinin kimliği değil +@msg {_1} is read-only {_1} salt okunurdur +@msg {what} must be from {lo} to {hi} {what} {lo} ile {hi} arasında olmalıdır +@msg a color component bir renk bileşeni +@msg a plate bir baskı plakası # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Bu izinleri isteyen “{name}” yüklensin mi? ID: {id} Kimlik: {id} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 151a886cf9..7f54787eeb 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5699,6 +5699,110 @@ axis Y Axis Вісь Y @msg a live effect runs while the document is drawn, so it can't be `heavy` динамічний ефект виконується під час відмальовування документа, тому він не може бути `heavy` @msg an exporter always gets an export's budget: leave `budget` out модуль експорту завжди отримує бюджет експорту: не вказуйте `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants «{_1}» запитує великий обчислювальний бюджет (кожен запуск до {minutes} хвилин і в {times} разів більше інструкцій, ніж зазвичай): його має схвалити людина в настільному застосунку, або передайте `allowHeavy: true` у сеансі, запущеному з --allow-plugin-grants +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` має містити 1-64 друковані символи +@msg `version`, `description` or `author` is too long `version`, `description` або `author` задовге +@msg `warnings` must be a list of strings `warnings` має бути списком рядків +@msg `{k}` must be one of {_1} `{k}` має бути одним з: {_1} +@msg `{k}` must be {what} `{k}` має бути: {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` модуль експорту оголошує від 1 до {MAX_EXTENSIONS} `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` поза групами може бути не більше {MAX_UNGROUPED} параметрів: задайте решті `group` +@msg can't encode the input не вдається закодувати вхідні дані +@msg expected {"dataBase64"} очікувалося {"dataBase64"} +@msg expected {"objects"} очікувалося {"objects"} +@msg export `{name}`: {e} експорт модуля `{name}`: {e} +@msg extension {e} is listed twice розширення {e} вказано двічі +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot розширення {e}: використовуйте 1-16 малих літер і цифр, без крапки +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - неприпустимий ID {_1}: використовуйте 1-64 символи з A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} неприпустиме ім’я параметра {k} +@msg manifest is not UTF-8 маніфест не в кодуванні UTF-8 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) маніфест задовгий (межа {MAX_MANIFEST_BYTES} байтів) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) маніфест займає {_1} байтів (межа {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) модуль займає {_1} байтів (межа {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own потрібне схвалення: він запитує {0}, що користувач дозволяє під час окремого встановлення +@msg not JSON: {e} не JSON: {e} +@msg not a WebAssembly binary (missing \0asm header) не двійковий файл WebAssembly (немає заголовка \0asm) +@msg parameter `{k}`: {what} параметр `{k}`: {what} +@msg parameter `{name}`: {what} параметр `{name}`: {what} +@msg plug-in parameters must be a JSON object параметри модуля мають бути об’єктом JSON +@msg the module must export its linear memory as `memory` модуль має експортувати свою лінійну пам’ять як `memory` +@msg the objects to change are no longer in the document об’єктів, які треба змінити, більше немає в документі +@msg the runtime panicked середовище виконання аварійно завершилося +@msg unexpected host trap неочікувана пастка (trap) хоста +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version повернула {v}; цей хост реалізує версію {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc повернула 0 (бракує пам’яті) +@msg vc_alloc returned a block outside memory vc_alloc повернула блок поза пам’яттю +@msg vc_run returned error code {packed} vc_run повернула код помилки {packed} +@msg {_1} is not a file {_1} не є файлом +@msg {_1} is {_2} bytes (limit {_3}) {_1} займає {_2} байтів (межа {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) об’єктів: {_1} (межа {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} вказує за межі пам’яті +@msg `angle` must be a number `angle` має бути числом +@msg `aspect` must be from 0.01 to 100 `aspect` має бути від 0.01 до 100 +@msg `cap` must be "butt", "round" or "square" `cap` має бути "butt", "round" або "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` має бути "nonzero" або "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` має бути адресою https:// довжиною не більше {MAX_HELP_URL} байтів +@msg `help` must be 1-{MAX_HELP} printable characters `help` має містити 1-{MAX_HELP} друкованих символів +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` має бути ідентифікатором ефекту довжиною 1-{MAX_EFFECT_ID} байтів +@msg `id` must be the id of an input object `id` має бути ідентифікатором вхідного об’єкта +@msg `join` must be "miter", "round" or "bevel" `join` має бути "miter", "round" або "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` і `group` мають містити 1-{MAX_LABEL} друкованих символів +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` має бути 1-{MAX_TEXT}, а типове значення не довшим +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` і `maxItems` мають бути 0-{MAX_LIST}, `maxItems` щонайменше 1, а типове значення має містити стільки елементів +@msg `name` must be a string `name` має бути рядком +@msg `opacity` must be a number `opacity` має бути числом +@msg `type` must be "path" or "compound" `type` має бути "path" або "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` має містити 1-{MAX_UNIT} друкованих символів +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` має перелічувати від 1 до {MAX_CONDITION_VALUES} значень для `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` указує `{other}`, але це не параметр, оголошений раніше за цей +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` указує не більше {MAX_CONDITIONS} параметрів +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` приймає значення `{other}`, прапорця або списку варіантів +@msg `visible` must be true or false `visible` має бути true або false +@msg `{key}` must be a number from {_1} to {_2} `{key}` має бути числом від {_1} до {_2} +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` має бути масивом не більше ніж із {MAX_ITEMS} елементів +@msg a color default must be a color object or a hex string such as "#3a7bd5" типовий колір має бути об’єктом кольору або шістнадцятковим рядком, наприклад "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" об’єкт кольору або шістнадцятковий рядок, наприклад "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines довільний градієнт містить не більше {MAX_ITEMS} точок і ліній +@msg a freeform gradient line names a point it doesn't have лінія довільного градієнта вказує точку, якої в градієнті немає +@msg a gradient needs 1-{MAX_ITEMS} stops градієнту потрібно 1-{MAX_ITEMS} точок переходу +@msg a list of numbers список чисел +@msg a list of {min_items} to {max_items} numbers список із {min_items}-{max_items} чисел +@msg a new object needs a `path` новому об’єкту потрібен `path` +@msg a number число +@msg a painted live effect returns only `objects` малювальний динамічний ефект повертає лише `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` частина малювального динамічного ефекту має лише `path`, `fillRule`, `fills` і `strokes` +@msg a read-only object was left out пропущено об’єкт лише для читання +@msg a read-only object was left out: return it, as {"id"} alone or as it was given пропущено об’єкт лише для читання: поверніть його як один {"id"} або в тому вигляді, у якому його передано +@msg a string рядок +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` обведення має лише `paint`, `width`, `join`, `cap` і `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} обведення має мати вигляд {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} ефект має мати вигляд {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` ефект на заливці повертає лише `fills` +@msg an effect on a stroke returns only `strokes` ефект на обведенні повертає лише `strokes` +@msg an object must be a JSON object об’єкт має бути об’єктом JSON +@msg bad range неприпустимий діапазон +@msg choices must be 1-64 non-empty, printable strings варіанти мають бути 1-64 непорожніми друкованими рядками +@msg coordinate out of range (limit ±{MAX_COORD} pt) координата поза діапазоном (межа ±{MAX_COORD} pt) +@msg default is not one of the options типове значення не входить до варіантів +@msg each piece needs a `path` кожній частині потрібен `path` +@msg is listed twice вказано двічі +@msg pattern transform out of range перетворення візерунка поза діапазоном +@msg read-only field '{field}' changed змінено поле лише для читання '{field}' +@msg stops must be in order of `offset` точки переходу мають іти в порядку `offset` +@msg the document has no pattern {pattern} у документі немає візерунка {pattern} +@msg the document has no swatch {n} у документі немає зразка {n} +@msg the output виведення +@msg the path has no anchor points контур не має опорних точок +@msg too many anchor points (limit {MAX_ANCHORS}) забагато опорних точок (межа {MAX_ANCHORS}) +@msg true or false true або false +@msg unknown key `{k}`; the keys here are {_1} невідомий ключ `{k}`; тут допустимі ключі {_1} +@msg {_1} is listed twice {_1} вказано двічі +@msg {_1} is not the id of an input object {_1} не є ідентифікатором вхідного об’єкта +@msg {_1} is read-only {_1} доступний лише для читання +@msg {what} must be from {lo} to {hi} {what}: значення має бути від {lo} до {hi} +@msg a color component компонент кольору +@msg a plate пластина # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Встановити «{name}», якому потрібні ці дозволи? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f3b505c648..8100163fe4 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5700,6 +5700,110 @@ axis Y Axis Trục Y @msg a live effect runs while the document is drawn, so it can't be `heavy` hiệu ứng trực tiếp chạy trong lúc tài liệu được vẽ nên không thể là `heavy` @msg an exporter always gets an export's budget: leave `budget` out plug-in xuất luôn nhận ngân sách của một lần xuất: hãy bỏ `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” yêu cầu ngân sách tính toán lớn (mỗi lần chạy tới {minutes} phút và gấp {times} lần số lệnh thông thường): cần một người phê duyệt trong ứng dụng máy tính, hoặc truyền `allowHeavy: true` trong phiên khởi động bằng --allow-plugin-grants +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` phải có 1-64 ký tự in được +@msg `version`, `description` or `author` is too long `version`, `description` hoặc `author` quá dài +@msg `warnings` must be a list of strings `warnings` phải là một danh sách chuỗi +@msg `{k}` must be one of {_1} `{k}` phải là một trong {_1} +@msg `{k}` must be {what} `{k}` phải là {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` plug-in xuất khai báo từ 1 đến {MAX_EXTENSIONS} `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` tối đa {MAX_UNGROUPED} tham số nằm ngoài nhóm: hãy đặt `group` cho các tham số còn lại +@msg can't encode the input không thể mã hóa dữ liệu đầu vào +@msg expected {"dataBase64"} cần có {"dataBase64"} +@msg expected {"objects"} cần có {"objects"} +@msg export `{name}`: {e} export `{name}` của mô-đun: {e} +@msg extension {e} is listed twice phần mở rộng {e} được liệt kê hai lần +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot phần mở rộng {e}: dùng 1-16 chữ thường và chữ số, không có dấu chấm +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - ID không hợp lệ {_1}: dùng 1-64 ký tự trong A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} tên tham số không hợp lệ {k} +@msg manifest is not UTF-8 manifest không phải UTF-8 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) manifest quá dài (giới hạn {MAX_MANIFEST_BYTES} byte) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) manifest có {_1} byte (giới hạn {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) mô-đun có {_1} byte (giới hạn {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own cần phê duyệt: plug-in yêu cầu {0}, quyền mà người dùng cấp khi cài đặt riêng plug-in đó +@msg not JSON: {e} không phải JSON: {e} +@msg not a WebAssembly binary (missing \0asm header) không phải tệp nhị phân WebAssembly (thiếu phần đầu \0asm) +@msg parameter `{k}`: {what} tham số `{k}`: {what} +@msg parameter `{name}`: {what} tham số `{name}`: {what} +@msg plug-in parameters must be a JSON object tham số plug-in phải là một đối tượng JSON +@msg the module must export its linear memory as `memory` mô-đun phải export bộ nhớ tuyến tính của nó dưới tên `memory` +@msg the objects to change are no longer in the document các đối tượng cần thay đổi không còn trong tài liệu +@msg the runtime panicked môi trường chạy bị lỗi nghiêm trọng +@msg unexpected host trap máy chủ gặp trap không mong muốn +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version trả về {v}; máy chủ này triển khai phiên bản {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc trả về 0 (hết bộ nhớ) +@msg vc_alloc returned a block outside memory vc_alloc trả về một khối nằm ngoài bộ nhớ +@msg vc_run returned error code {packed} vc_run trả về mã lỗi {packed} +@msg {_1} is not a file {_1} không phải là tệp +@msg {_1} is {_2} bytes (limit {_3}) {_1} có {_2} byte (giới hạn {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} đối tượng (giới hạn {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} trỏ ra ngoài bộ nhớ +@msg `angle` must be a number `angle` phải là một số +@msg `aspect` must be from 0.01 to 100 `aspect` phải từ 0.01 đến 100 +@msg `cap` must be "butt", "round" or "square" `cap` phải là "butt", "round" hoặc "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` phải là "nonzero" hoặc "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` phải là một địa chỉ https:// dài tối đa {MAX_HELP_URL} byte +@msg `help` must be 1-{MAX_HELP} printable characters `help` phải có 1-{MAX_HELP} ký tự in được +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` phải là một id hiệu ứng dài 1-{MAX_EFFECT_ID} byte +@msg `id` must be the id of an input object `id` phải là id của một đối tượng đầu vào +@msg `join` must be "miter", "round" or "bevel" `join` phải là "miter", "round" hoặc "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` và `group` phải có 1-{MAX_LABEL} ký tự in được +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` phải là 1-{MAX_TEXT}, và giá trị mặc định không được dài hơn +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` và `maxItems` phải là 0-{MAX_LIST}, `maxItems` ít nhất là 1, và giá trị mặc định phải có đúng số mục đó +@msg `name` must be a string `name` phải là một chuỗi +@msg `opacity` must be a number `opacity` phải là một số +@msg `type` must be "path" or "compound" `type` phải là "path" hoặc "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` phải có 1-{MAX_UNIT} ký tự in được +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` phải liệt kê 1 đến {MAX_CONDITION_VALUES} giá trị cho `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` nêu `{other}`, nhưng đó không phải là tham số được khai báo trước tham số này +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` nêu tối đa {MAX_CONDITIONS} tham số +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` nhận giá trị của `{other}`, là một hộp kiểm hoặc một lựa chọn +@msg `visible` must be true or false `visible` phải là true hoặc false +@msg `{key}` must be a number from {_1} to {_2} `{key}` phải là một số từ {_1} đến {_2} +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` phải là một mảng có tối đa {MAX_ITEMS} mục +@msg a color default must be a color object or a hex string such as "#3a7bd5" màu mặc định phải là một đối tượng màu hoặc một chuỗi hex như "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" một đối tượng màu hoặc một chuỗi hex như "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines chuyển sắc tự do chứa tối đa {MAX_ITEMS} điểm và đường +@msg a freeform gradient line names a point it doesn't have một đường của chuyển sắc tự do nêu một điểm mà chuyển sắc không có +@msg a gradient needs 1-{MAX_ITEMS} stops chuyển sắc cần 1-{MAX_ITEMS} điểm dừng +@msg a list of numbers một danh sách số +@msg a list of {min_items} to {max_items} numbers một danh sách gồm {min_items} đến {max_items} số +@msg a new object needs a `path` đối tượng mới cần một `path` +@msg a number một số +@msg a painted live effect returns only `objects` hiệu ứng động kiểu tô chỉ trả về `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` một phần của hiệu ứng động kiểu tô chỉ có `path`, `fillRule`, `fills` và `strokes` +@msg a read-only object was left out một đối tượng chỉ đọc đã bị bỏ sót +@msg a read-only object was left out: return it, as {"id"} alone or as it was given một đối tượng chỉ đọc đã bị bỏ sót: hãy trả lại nó dưới dạng chỉ {"id"} hoặc như khi được nhận +@msg a string một chuỗi +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` nét chỉ có `paint`, `width`, `join`, `cap` và `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} nét phải có dạng {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} hiệu ứng phải có dạng {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` hiệu ứng trên phần tô chỉ trả về `fills` +@msg an effect on a stroke returns only `strokes` hiệu ứng trên nét chỉ trả về `strokes` +@msg an object must be a JSON object đối tượng phải là một đối tượng JSON +@msg bad range phạm vi không hợp lệ +@msg choices must be 1-64 non-empty, printable strings các lựa chọn phải là 1-64 chuỗi in được và không rỗng +@msg coordinate out of range (limit ±{MAX_COORD} pt) tọa độ vượt ngoài phạm vi (giới hạn ±{MAX_COORD} pt) +@msg default is not one of the options giá trị mặc định không nằm trong các tùy chọn +@msg each piece needs a `path` mỗi phần cần một `path` +@msg is listed twice được liệt kê hai lần +@msg pattern transform out of range phép biến đổi họa tiết vượt ngoài phạm vi +@msg read-only field '{field}' changed trường chỉ đọc '{field}' đã bị thay đổi +@msg stops must be in order of `offset` các điểm dừng phải theo thứ tự của `offset` +@msg the document has no pattern {pattern} tài liệu không có họa tiết {pattern} +@msg the document has no swatch {n} tài liệu không có mẫu màu {n} +@msg the output đầu ra +@msg the path has no anchor points đường dẫn không có điểm neo +@msg too many anchor points (limit {MAX_ANCHORS}) quá nhiều điểm neo (giới hạn {MAX_ANCHORS}) +@msg true or false true hoặc false +@msg unknown key `{k}`; the keys here are {_1} khóa không xác định `{k}`; các khóa ở đây là {_1} +@msg {_1} is listed twice {_1} được liệt kê hai lần +@msg {_1} is not the id of an input object {_1} không phải là id của một đối tượng đầu vào +@msg {_1} is read-only {_1} là chỉ đọc +@msg {what} must be from {lo} to {hi} {what} phải từ {lo} đến {hi} +@msg a color component một thành phần màu +@msg a plate một bản in # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Cài đặt “{name}”, plug-in yêu cầu các quyền này? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c7219b9448..5c7c64a976 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5932,6 +5932,110 @@ axis Y Axis Y 轴 @msg a live effect runs while the document is drawn, so it can't be `heavy` 实时效果在绘制文档时运行,因此不能为 `heavy` @msg an exporter always gets an export's budget: leave `budget` out 导出增效工具始终使用导出的预算:请省略 `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}”需要大量计算预算(每次运行最多 {minutes} 分钟,指令数为通常的 {times} 倍):需要有人在桌面应用中批准,或在以 --allow-plugin-grants 启动的会话中传入 `allowHeavy: true` +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` 必须是 1-64 个可打印字符 +@msg `version`, `description` or `author` is too long `version`、`description` 或 `author` 太长 +@msg `warnings` must be a list of strings `warnings` 必须是字符串列表 +@msg `{k}` must be one of {_1} `{k}` 必须是 {_1} 之一 +@msg `{k}` must be {what} `{k}` 必须是 {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` 导出增效工具声明 1 到 {MAX_EXTENSIONS} 个 `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` 组外最多 {MAX_UNGROUPED} 个参数:请为其余参数指定 `group` +@msg can't encode the input 无法编码输入 +@msg expected {"dataBase64"} 应为 {"dataBase64"} +@msg expected {"objects"} 应为 {"objects"} +@msg export `{name}`: {e} 模块导出 `{name}`:{e} +@msg extension {e} is listed twice 扩展名 {e} 列出了两次 +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot 扩展名 {e}:请使用 1-16 个小写字母和数字,不带点 +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - 无效的 ID {_1}:请使用 1-64 个 A-Z a-z 0-9 . _ - 中的字符 +@msg invalid parameter name {k} 无效的参数名称 {k} +@msg manifest is not UTF-8 清单不是 UTF-8 编码 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) 清单太长(上限 {MAX_MANIFEST_BYTES} 字节) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) 清单为 {_1} 字节(上限 {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) 模块为 {_1} 字节(上限 {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own 需要批准:它请求 {0},用户单独安装它时授予这些权限 +@msg not JSON: {e} 不是 JSON:{e} +@msg not a WebAssembly binary (missing \0asm header) 不是 WebAssembly 二进制文件(缺少 \0asm 头) +@msg parameter `{k}`: {what} 参数 `{k}`:{what} +@msg parameter `{name}`: {what} 参数 `{name}`:{what} +@msg plug-in parameters must be a JSON object 增效工具参数必须是 JSON 对象 +@msg the module must export its linear memory as `memory` 模块必须将其线性内存导出为 `memory` +@msg the objects to change are no longer in the document 要更改的对象已不在文档中 +@msg the runtime panicked 运行时崩溃 +@msg unexpected host trap 宿主发生意外的 trap +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version 返回了 {v};此宿主实现的是版本 {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc 返回了 0(内存不足) +@msg vc_alloc returned a block outside memory vc_alloc 返回了内存之外的块 +@msg vc_run returned error code {packed} vc_run 返回了错误代码 {packed} +@msg {_1} is not a file {_1} 不是文件 +@msg {_1} is {_2} bytes (limit {_3}) {_1} 为 {_2} 字节(上限 {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} 个对象(上限 {MAX_OBJECTS}) +@msg {at}: {msg} {at}:{msg} +@msg {what} points outside memory {what} 指向内存之外 +@msg `angle` must be a number `angle` 必须是数字 +@msg `aspect` must be from 0.01 to 100 `aspect` 必须在 0.01 到 100 之间 +@msg `cap` must be "butt", "round" or "square" `cap` 必须是 "butt"、"round" 或 "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` 必须是 "nonzero" 或 "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` 必须是不超过 {MAX_HELP_URL} 字节的 https:// 地址 +@msg `help` must be 1-{MAX_HELP} printable characters `help` 必须是 1-{MAX_HELP} 个可打印字符 +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` 必须是 1-{MAX_EFFECT_ID} 字节的效果 ID +@msg `id` must be the id of an input object `id` 必须是某个输入对象的 ID +@msg `join` must be "miter", "round" or "bevel" `join` 必须是 "miter"、"round" 或 "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` 和 `group` 必须是 1-{MAX_LABEL} 个可打印字符 +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` 必须是 1-{MAX_TEXT},且默认值不能更长 +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` 和 `maxItems` 必须是 0-{MAX_LIST},`maxItems` 至少为 1,且默认值的项数在此范围内 +@msg `name` must be a string `name` 必须是字符串 +@msg `opacity` must be a number `opacity` 必须是数字 +@msg `type` must be "path" or "compound" `type` 必须是 "path" 或 "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` 必须是 1-{MAX_UNIT} 个可打印字符 +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` 必须为 `{other}` 列出 1 到 {MAX_CONDITION_VALUES} 个值 +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` 指定了 `{other}`,但它不是在此参数之前声明的参数 +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` 最多指定 {MAX_CONDITIONS} 个参数 +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` 接受 `{other}` 的值,该参数须为复选框或选项 +@msg `visible` must be true or false `visible` 必须是 true 或 false +@msg `{key}` must be a number from {_1} to {_2} `{key}` 必须是 {_1} 到 {_2} 之间的数字 +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` 必须是最多 {MAX_ITEMS} 项的数组 +@msg a color default must be a color object or a hex string such as "#3a7bd5" 颜色默认值必须是颜色对象或 "#3a7bd5" 这样的十六进制字符串 +@msg a color object or a hex string such as "#3a7bd5" 颜色对象或 "#3a7bd5" 这样的十六进制字符串 +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines 任意形状渐变最多包含 {MAX_ITEMS} 个点和线 +@msg a freeform gradient line names a point it doesn't have 任意形状渐变的一条线指定了渐变中没有的点 +@msg a gradient needs 1-{MAX_ITEMS} stops 渐变需要 1-{MAX_ITEMS} 个色标 +@msg a list of numbers 数字列表 +@msg a list of {min_items} to {max_items} numbers 包含 {min_items} 到 {max_items} 个数字的列表 +@msg a new object needs a `path` 新对象需要 `path` +@msg a number 数字 +@msg a painted live effect returns only `objects` 绘制型实时效果只返回 `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` 绘制型实时效果的片段只有 `path`、`fillRule`、`fills` 和 `strokes` +@msg a read-only object was left out 漏掉了一个只读对象 +@msg a read-only object was left out: return it, as {"id"} alone or as it was given 漏掉了一个只读对象:请以单独的 {"id"} 或原样返回它 +@msg a string 字符串 +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` 描边只有 `paint`、`width`、`join`、`cap` 和 `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 描边必须是 {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 形式 +@msg an effect must be {"id", "params"?, "visible"?} 效果必须是 {"id", "params"?, "visible"?} 形式 +@msg an effect on a fill returns only `fills` 填色上的效果只返回 `fills` +@msg an effect on a stroke returns only `strokes` 描边上的效果只返回 `strokes` +@msg an object must be a JSON object 对象必须是 JSON 对象 +@msg bad range 范围无效 +@msg choices must be 1-64 non-empty, printable strings 选项必须是 1-64 个非空的可打印字符串 +@msg coordinate out of range (limit ±{MAX_COORD} pt) 坐标超出范围(上限 ±{MAX_COORD} pt) +@msg default is not one of the options 默认值不是选项之一 +@msg each piece needs a `path` 每个片段都需要 `path` +@msg is listed twice 列出了两次 +@msg pattern transform out of range 图案变换超出范围 +@msg read-only field '{field}' changed 只读字段 '{field}' 已更改 +@msg stops must be in order of `offset` 色标必须按 `offset` 排序 +@msg the document has no pattern {pattern} 文档中没有图案 {pattern} +@msg the document has no swatch {n} 文档中没有色板 {n} +@msg the output 输出 +@msg the path has no anchor points 路径没有锚点 +@msg too many anchor points (limit {MAX_ANCHORS}) 锚点过多(上限 {MAX_ANCHORS}) +@msg true or false true 或 false +@msg unknown key `{k}`; the keys here are {_1} 未知键 `{k}`;此处可用的键:{_1} +@msg {_1} is listed twice {_1} 列出了两次 +@msg {_1} is not the id of an input object {_1} 不是输入对象的 ID +@msg {_1} is read-only {_1} 为只读 +@msg {what} must be from {lo} to {hi} {what} 必须在 {lo} 到 {hi} 之间 +@msg a color component 颜色分量 +@msg a plate 印版 # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? 要安装需要这些权限的“{name}”吗? ID: {id} ID:{id} diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 1c753576a5..bad5bc8e16 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5737,6 +5737,110 @@ axis Y Axis Y 軸 @msg a live effect runs while the document is drawn, so it can't be `heavy` 即時效果會在繪製文件時執行,因此不能是 `heavy` @msg an exporter always gets an export's budget: leave `budget` out 匯出增效模組一律使用匯出的預算:請移除 `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants 「{_1}」需要大量運算預算(每次執行最多 {minutes} 分鐘,指令數為一般的 {times} 倍):需要有人在桌面應用程式中核准,或在以 --allow-plugin-grants 啟動的工作階段中傳入 `allowHeavy: true` +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` 必須是 1-64 個可列印字元 +@msg `version`, `description` or `author` is too long `version`、`description` 或 `author` 太長 +@msg `warnings` must be a list of strings `warnings` 必須是字串清單 +@msg `{k}` must be one of {_1} `{k}` 必須是 {_1} 其中之一 +@msg `{k}` must be {what} `{k}` 必須是 {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` 匯出增效模組宣告 1 到 {MAX_EXTENSIONS} 個 `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` 群組外最多 {MAX_UNGROUPED} 個參數:請為其餘參數指定 `group` +@msg can't encode the input 無法編碼輸入 +@msg expected {"dataBase64"} 應為 {"dataBase64"} +@msg expected {"objects"} 應為 {"objects"} +@msg export `{name}`: {e} 模組匯出 `{name}`:{e} +@msg extension {e} is listed twice 副檔名 {e} 列出了兩次 +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot 副檔名 {e}:請使用 1-16 個小寫字母和數字,不含點 +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - 無效的 ID {_1}:請使用 1-64 個 A-Z a-z 0-9 . _ - 中的字元 +@msg invalid parameter name {k} 無效的參數名稱 {k} +@msg manifest is not UTF-8 資訊清單不是 UTF-8 編碼 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) 資訊清單太長(上限 {MAX_MANIFEST_BYTES} 位元組) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) 資訊清單為 {_1} 位元組(上限 {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) 模組為 {_1} 位元組(上限 {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own 需要核准:它要求 {0},使用者單獨安裝它時授予這些權限 +@msg not JSON: {e} 不是 JSON:{e} +@msg not a WebAssembly binary (missing \0asm header) 不是 WebAssembly 二進位檔(沒有 \0asm 標頭) +@msg parameter `{k}`: {what} 參數 `{k}`:{what} +@msg parameter `{name}`: {what} 參數 `{name}`:{what} +@msg plug-in parameters must be a JSON object 增效模組參數必須是 JSON 物件 +@msg the module must export its linear memory as `memory` 模組必須將其線性記憶體匯出為 `memory` +@msg the objects to change are no longer in the document 要變更的物件已不在文件中 +@msg the runtime panicked 執行階段當機 +@msg unexpected host trap 主機發生非預期的 trap +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version 傳回了 {v};此主機實作的是版本 {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc 傳回了 0(記憶體不足) +@msg vc_alloc returned a block outside memory vc_alloc 傳回了記憶體之外的區塊 +@msg vc_run returned error code {packed} vc_run 傳回了錯誤代碼 {packed} +@msg {_1} is not a file {_1} 不是檔案 +@msg {_1} is {_2} bytes (limit {_3}) {_1} 為 {_2} 位元組(上限 {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} 個物件(上限 {MAX_OBJECTS}) +@msg {at}: {msg} {at}:{msg} +@msg {what} points outside memory {what} 指向記憶體之外 +@msg `angle` must be a number `angle` 必須是數字 +@msg `aspect` must be from 0.01 to 100 `aspect` 必須介於 0.01 到 100 之間 +@msg `cap` must be "butt", "round" or "square" `cap` 必須是 "butt"、"round" 或 "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` 必須是 "nonzero" 或 "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` 必須是不超過 {MAX_HELP_URL} 位元組的 https:// 位址 +@msg `help` must be 1-{MAX_HELP} printable characters `help` 必須是 1-{MAX_HELP} 個可列印字元 +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` 必須是 1-{MAX_EFFECT_ID} 位元組的效果 ID +@msg `id` must be the id of an input object `id` 必須是某個輸入物件的 ID +@msg `join` must be "miter", "round" or "bevel" `join` 必須是 "miter"、"round" 或 "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` 和 `group` 必須是 1-{MAX_LABEL} 個可列印字元 +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` 必須是 1-{MAX_TEXT},且預設值不能更長 +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` 和 `maxItems` 必須是 0-{MAX_LIST},`maxItems` 至少為 1,且預設值的項目數須在此範圍內 +@msg `name` must be a string `name` 必須是字串 +@msg `opacity` must be a number `opacity` 必須是數字 +@msg `type` must be "path" or "compound" `type` 必須是 "path" 或 "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` 必須是 1-{MAX_UNIT} 個可列印字元 +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` 必須為 `{other}` 列出 1 到 {MAX_CONDITION_VALUES} 個值 +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` 指定了 `{other}`,但它不是在此參數之前宣告的參數 +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` 最多指定 {MAX_CONDITIONS} 個參數 +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` 接受 `{other}` 的值,該參數須為核取方塊或選項 +@msg `visible` must be true or false `visible` 必須是 true 或 false +@msg `{key}` must be a number from {_1} to {_2} `{key}` 必須是介於 {_1} 到 {_2} 之間的數字 +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` 必須是最多 {MAX_ITEMS} 項的陣列 +@msg a color default must be a color object or a hex string such as "#3a7bd5" 色彩預設值必須是色彩物件或 "#3a7bd5" 這類十六進位字串 +@msg a color object or a hex string such as "#3a7bd5" 色彩物件或 "#3a7bd5" 這類十六進位字串 +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines 任意形狀漸層最多包含 {MAX_ITEMS} 個點和線 +@msg a freeform gradient line names a point it doesn't have 任意形狀漸層的一條線指定了漸層中沒有的點 +@msg a gradient needs 1-{MAX_ITEMS} stops 漸層需要 1-{MAX_ITEMS} 個色標 +@msg a list of numbers 數字清單 +@msg a list of {min_items} to {max_items} numbers 包含 {min_items} 到 {max_items} 個數字的清單 +@msg a new object needs a `path` 新物件需要 `path` +@msg a number 數字 +@msg a painted live effect returns only `objects` 繪製型即時效果只傳回 `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` 繪製型即時效果的片段只有 `path`、`fillRule`、`fills` 和 `strokes` +@msg a read-only object was left out 漏掉了一個唯讀物件 +@msg a read-only object was left out: return it, as {"id"} alone or as it was given 漏掉了一個唯讀物件:請以單獨的 {"id"} 或原樣傳回它 +@msg a string 字串 +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` 筆畫只有 `paint`、`width`、`join`、`cap` 和 `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 筆畫必須是 {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 形式 +@msg an effect must be {"id", "params"?, "visible"?} 效果必須是 {"id", "params"?, "visible"?} 形式 +@msg an effect on a fill returns only `fills` 填色上的效果只傳回 `fills` +@msg an effect on a stroke returns only `strokes` 筆畫上的效果只傳回 `strokes` +@msg an object must be a JSON object 物件必須是 JSON 物件 +@msg bad range 範圍無效 +@msg choices must be 1-64 non-empty, printable strings 選項必須是 1-64 個非空白的可列印字串 +@msg coordinate out of range (limit ±{MAX_COORD} pt) 座標超出範圍(上限 ±{MAX_COORD} pt) +@msg default is not one of the options 預設值不是選項之一 +@msg each piece needs a `path` 每個片段都需要 `path` +@msg is listed twice 列出了兩次 +@msg pattern transform out of range 圖樣變形超出範圍 +@msg read-only field '{field}' changed 唯讀欄位 '{field}' 已變更 +@msg stops must be in order of `offset` 色標必須依 `offset` 排序 +@msg the document has no pattern {pattern} 文件中沒有圖樣 {pattern} +@msg the document has no swatch {n} 文件中沒有色票 {n} +@msg the output 輸出 +@msg the path has no anchor points 路徑沒有錨點 +@msg too many anchor points (limit {MAX_ANCHORS}) 錨點過多(上限 {MAX_ANCHORS}) +@msg true or false true 或 false +@msg unknown key `{k}`; the keys here are {_1} 未知的鍵 `{k}`;此處可用的鍵:{_1} +@msg {_1} is listed twice {_1} 列出了兩次 +@msg {_1} is not the id of an input object {_1} 不是輸入物件的 ID +@msg {_1} is read-only {_1} 為唯讀 +@msg {what} must be from {lo} to {hi} {what} 必須介於 {lo} 到 {hi} 之間 +@msg a color component 色彩分量 +@msg a plate 色版 # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? 要安裝需要這些權限的「{name}」嗎? ID: {id} ID:{id} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f28078ec33..2f76dae1df 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -814,6 +814,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Czech render-site labels: desktop/web file filters, diagnostics, suggested preset names and contributor tooltips/plurals now use catalog lookups; gradients have Czech adjective agreement and printer marks have a separate context (PDF/Print and Summary). Search, Home, toolbar/panel tooltips and Preferences already look up their labels. Every complete catalog has rows for these labels and for the Roman and Japanese printer mark styles. Evidence: `i18n::tests::czech_render_site_follow_up`, `i18n::tests::complete_catalogs_translate_render_site_labels`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md). - The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`). - The status bar translates the limits a plug-in run can exceed (its instruction, time and memory budgets and its output size) and the error of a plug-in removal that leaves the plug-in's grant in the plug-in store, in every language that translates every message. The untranslated-strings report also lists messages that an error or a status takes from a `&str` constant (`plugin_limit_errors_are_translated`, `message_scan_reads_messages_held_in_constants`). + - The status bar translates the plug-in host's other errors (manifest, module and run checks, the output an importer, exporter or effect returns, and the approval a plug-in's permissions need) in every language that translates every message. A JSON object or array inside a message stays as written, and a message that wraps another one, such as `plug-in manifest: {0}`, is matched by its own row before a row that starts with a placeholder. The reasons inside these errors (what is wrong with a parameter's spec or dialog keys, the value a parameter needs, what is wrong with an object, stroke, paint or effect in a plug-in's output) are translated with them, and the untranslated-strings report lists the reasons the plug-in host's error helpers take (`plugin_messages_are_translated_in_every_complete_catalog`, `plugin_errors_translate_with_their_values`, `plugin_error_reasons_are_translated_with_the_error`, `message_scan_reads_plugin_helpers`, `templates_that_start_with_text_match_first`). - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Korean, Vietnamese, German, Spanish, French, Italian, Polish, Brazilian Portuguese, Russian, Ukrainian, Georgian and Catalan ship complete, and Czech covers every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). The counts that Package's status and question, the folder search of Missing Fonts and Find Linked Files, the modified and missing linked file prompts and the CSS Properties panel's notes show use the interface language's plural forms instead of a fixed "(s)" form. Evidence: `the_missing_links_note_uses_each_count_form` in `crates/ui-egui/src/dialogs/tests_package.rs`, `the_modified_links_question_uses_each_count_form` in `crates/ui-egui/src/dialogs/missing_links.rs`, `search_counts_use_each_count_form` in `crates/ui-egui/src/dialogs/missing_fonts/tests.rs` and `the_unnamed_objects_note_uses_each_count_form` in `crates/ui-egui/src/panels/tests_css_properties.rs`. The status bar's messages for relinked, replaced, updated and embedded images, broken links, linked files that can't be read or changed on disk, images that can't be decoded, added and unavailable fonts, copied CSS, downloads and exports with their notes name one item in the singular and more in the plural, in English and in each language's plural forms (`counted_status_messages_take_the_plural_form` in `crates/ui-egui/src/i18n/tests.rs`). From 6ccb7c907756400aa6c360edaa350834716ecac7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:36:03 -0800 Subject: [PATCH 18/92] 3D and Materials: Expand as Wireframes expands Revolve (Classic) and Rotate (Classic) effect.expandAsWireframes returned "draws no faces" for an object with Revolve (Classic). It now takes the faces of Revolve (Classic) with that effect's own options, and Rotate (Classic) is expanded as Rotate is: every fill and stroke turned as one plane. Docs: docs/mcp.md, docs/three-d.md. --- crates/effects/src/revolve.rs | 24 ++++++++------ crates/effects/src/solids/wireframe.rs | 14 ++++----- crates/effects/src/solids/wireframe/tests.rs | 33 ++++++++++++++++++++ crates/engine/src/cmd/wireframe.rs | 2 +- crates/engine/src/cmd/wireframe/tests.rs | 10 +++++- docs/mcp.md | 4 +-- docs/three-d.md | 2 +- 7 files changed, 67 insertions(+), 22 deletions(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 9d31e8372d..8d05ab38ed 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -507,9 +507,9 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul Ok(out) } -/// The outline of every face [`revolve_art`] draws for `n` (its symbol instances, blends and clipping -/// groups already resolved), in document coordinates: with the effect on the object, the surface in -/// its top visible fill (else its stroke); with it on fills and strokes, one surface for each of +/// The outline of every face [`revolve_art`] draws for `n`'s Revolve or Revolve (Classic) (its symbol +/// instances, blends and clipping groups already resolved), in document coordinates: with the +/// effect on the object, the surface in its top visible fill (else its stroke); with it on fills and strokes, one surface for each of /// them, reshaped by the geometry effects after each one's Revolve; on a group or layer, one for /// each member, about one shared axis. pub(crate) fn revolve_face_paths(n: &Node) -> Result, String> { @@ -517,13 +517,17 @@ pub(crate) fn revolve_face_paths(n: &Node) -> Result, String> { Ok(vectorcraft_three_d::revolve(path, b, options)?.iter().map(vectorcraft_three_d::Face::path).collect()) }; if is_container(n) { - let index = - n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE).ok_or("a 3D effect on a group's fill or stroke draws nothing")?; + let index = n + .appearance + .effects + .iter() + .position(|e| e.visible && is_revolve(&e.id)) + .ok_or("a 3D effect on a group's fill or stroke draws nothing")?; let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) }; let mut shapes = members(n, "Revolve")?; reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes); let b = union_bounds(&shapes).ok_or("Revolve requires a group holding a shape with at least two anchors")?; - let options = shared_options(&shapes, revolve_options(&merged_params(REVOLVE, &fx.params)))?; + let options = shared_options(&shapes, fx_options(fx))?; let mut out = vec![]; for s in shapes.iter().filter(|s| surface_item(&s.items).is_some()) { out.extend(faces(&s.path, b, options)?); @@ -532,24 +536,24 @@ pub(crate) fn revolve_face_paths(n: &Node) -> Result, String> { } let (path, _) = object_profile(n, "Revolve")?; let ctx = GeomContext::of(n); - if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) { + if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id)) { let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) }; let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx); if surface_item(&solid_paints(n)).is_none() { return Err("the revolved object has no fill or stroke".into()); } let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?; - return faces(&path, b, revolve_options(&merged_params(REVOLVE, &fx.params))); + return faces(&path, b, fx_options(fx)); } let path = geometry(&path, &n.appearance.effects, &ctx); let mut out = vec![]; for item in n.appearance.items.iter().filter(|i| crate::paints(i)) { - if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) + if let Some(index) = item.effects().iter().position(|e| e.visible && is_revolve(&e.id)) && let Some(fx) = item.effects().get(index) { let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); let b = p.bounds().ok_or("Revolve requires a shape with at least two anchors")?; - let surface = faces(&p, b, revolve_options(&merged_params(REVOLVE, &fx.params)))?; + let surface = faces(&p, b, fx_options(fx))?; out.extend(crate::solids::wireframe::reshaped(surface, item.effects().get(index + 1..).unwrap_or_default(), &ctx)); } } diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs index eb74546225..e2656222b3 100644 --- a/crates/effects/src/solids/wireframe.rs +++ b/crates/effects/src/solids/wireframe.rs @@ -7,8 +7,8 @@ use vectorcraft_geom::{Anchor, CubicBez, PathData, Point, SubPath}; use vectorcraft_three_d::Facet; use super::{ - INFLATE, Profile, ROTATE, facets, geometry, has_solid, inflate_options, is_container, is_three_d, label_of, members, object_profile, region, - reshape_members, resolve, solid, solid_paints, solid_parts, union_bounds, view_options, + INFLATE, Profile, ROTATE, facets, geometry, has_solid, inflate_options, is_container, is_plane, is_three_d, label_of, members, object_profile, + region, reshape_members, resolve, solid, solid_paints, solid_parts, union_bounds, view_options, }; use crate::{GeomContext, apply_geometry_with, is_geometry}; @@ -21,8 +21,8 @@ const GRID: f64 = 1.0 / 1024.0; const NO_EFFECT: &str = "the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate)"; -/// The wireframe of `n`'s 3D effect (Extrude & Bevel, Extrude & Bevel (Classic), Revolve, Inflate -/// or Rotate), in document coordinates: the edges of every face the effect draws for the object as +/// The wireframe of `n`'s 3D effect (Extrude & Bevel, Revolve, Inflate, Rotate or one of their 3D +/// (Classic) forms), in document coordinates: the edges of every face the effect draws for the object as /// it is turned and projected, each edge once. Edges that meet end to end are joined into one path; /// a run that comes back to where it started is closed. A curved edge stays a curve. The geometry /// effects after the object's 3D effect reshape the wireframe as they reshape the drawn art. On a @@ -108,8 +108,8 @@ fn solid_face_paths(n: &Node) -> Result, String> { Ok(out) } -/// The outlines of the facets effect `fx` builds from `shapes`, as `art_for` builds them: -/// Rotate turns every fill and stroke; Extrude & Bevel and Inflate build a solid from each shape's +/// The outlines of the facets effect `fx` builds from `shapes`, as `art_for` builds them: Rotate and +/// Rotate (Classic) turn every fill and stroke; Extrude & Bevel and Inflate build a solid from each shape's /// top fill and from the outline of each stroke. A stroke whose solid is past the limits is left out, /// as it is left out of the drawn art. fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result, String> { @@ -117,7 +117,7 @@ fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result, String> let b = union_bounds(shapes).ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; let p = crate::merged_params(&fx.id, &fx.params); let mut out = vec![]; - if fx.id == ROTATE { + if is_plane(&fx.id) { for s in shapes { for item in &s.items { let (area, rule) = region(&s.path, s.rule, item); diff --git a/crates/effects/src/solids/wireframe/tests.rs b/crates/effects/src/solids/wireframe/tests.rs index 980e67bbeb..8498885776 100644 --- a/crates/effects/src/solids/wireframe/tests.rs +++ b/crates/effects/src/solids/wireframe/tests.rs @@ -124,6 +124,39 @@ fn revolve_inflate_and_rotate_expand_too() { assert_eq!(segments(&plane).len(), 4); } +/// Revolve (Classic) and Rotate (Classic) expand to the edges of the faces they draw, in the same +/// place. +#[test] +fn revolve_classic_and_rotate_classic_expand_too() { + let n = solid_square(crate::CLASSIC_REVOLVE, json!({})); + let wire = three_d_wireframe_in(None, &n).unwrap(); + assert!(!wire.is_empty()); + let art = crate::revolve_art(&n).unwrap(); + let (w, a) = (bounds(&wire), bounds(&face_paths(&art))); + for (x, y) in [(w.x0, a.x0), (w.y0, a.y0), (w.x1, a.x1), (w.y1, a.y1)] { + assert!((x - y).abs() < 1e-6, "{w:?} {a:?}"); + } + // On a fill, with a geometry effect after it. + let mut n = solid_square(crate::CLASSIC_REVOLVE, json!({})); + let fx = n.appearance.effects.remove(0); + for item in &mut n.appearance.items { + if matches!(item, AppearanceItem::Fill(_)) { + item.effects_mut().push(fx.clone()); + } + } + let plain = bounds(&three_d_wireframe_in(None, &n).unwrap()); + for item in &mut n.appearance.items { + if matches!(item, AppearanceItem::Fill(_)) { + item.effects_mut().push(crate::new_effect("distort.transform", &json!({"moveH": 50.0})).unwrap()); + } + } + let moved = bounds(&three_d_wireframe_in(None, &n).unwrap()); + assert!((moved.x0 - plain.x0 - 50.0).abs() < 1e-6 && (moved.y0 - plain.y0).abs() < 1e-6, "{plain:?} {moved:?}"); + // Rotate (Classic) is one flat face: its outline. + let plane = three_d_wireframe_in(None, &solid_square(super::super::CLASSIC_ROTATE, json!({}))).unwrap(); + assert_eq!(segments(&plane).len(), 4); +} + /// A geometry effect after a fill's 3D effect moves that fill's edges, as it moves its drawn surface. #[test] fn a_geometry_effect_after_a_fill_s_3d_effect_reshapes_its_edges() { diff --git a/crates/engine/src/cmd/wireframe.rs b/crates/engine/src/cmd/wireframe.rs index 27a35a793b..42dfaf8a33 100644 --- a/crates/engine/src/cmd/wireframe.rs +++ b/crates/engine/src/cmd/wireframe.rs @@ -25,7 +25,7 @@ pub fn specs() -> Vec { "Expand as Wireframes", [], None, - "{ids?: [..] (default: the selected objects)} replace each object that has a visible 3D effect (Extrude & Bevel, Extrude & Bevel (Classic), Revolve, Inflate or Rotate, on the object or on its fills and strokes) with a group of paths: the edges of the faces the effect draws, as the object is turned and projected now, each edge once (edges that meet end to end make one path, closed where it comes back to its start; curved edges stay curves; geometry effects after the 3D effect, on the object or on a fill or stroke, reshape them too), stroked 0.5 pt black with round joins and no fill (VectorCraft's own); the group takes the object's place, id, name, opacity, blending mode, opacity mask, attributes, slice and text wrap and is selected (a layer stays a layer holding the edges, which are selected); objects without a visible 3D effect are left as they are; one undo step. An error, with nothing changed, when no object is selected or given, when none has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges → {ids}", + "{ids?: [..] (default: the selected objects)} replace each object that has a visible 3D effect (Extrude & Bevel, Revolve, Inflate, Rotate or one of their 3D (Classic) forms, on the object or on its fills and strokes) with a group of paths: the edges of the faces the effect draws, as the object is turned and projected now, each edge once (edges that meet end to end make one path, closed where it comes back to its start; curved edges stay curves; geometry effects after the 3D effect, on the object or on a fill or stroke, reshape them too), stroked 0.5 pt black with round joins and no fill (VectorCraft's own); the group takes the object's place, id, name, opacity, blending mode, opacity mask, attributes, slice and text wrap and is selected (a layer stays a layer holding the edges, which are selected); objects without a visible 3D effect are left as they are; one undo step. An error, with nothing changed, when no object is selected or given, when none has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges → {ids}", has_doc, expand_as_wireframes )] diff --git a/crates/engine/src/cmd/wireframe/tests.rs b/crates/engine/src/cmd/wireframe/tests.rs index 67e338b781..ce881982cd 100644 --- a/crates/engine/src/cmd/wireframe/tests.rs +++ b/crates/engine/src/cmd/wireframe/tests.rs @@ -27,7 +27,15 @@ fn run(s: &mut Session, p: Value) -> crate::Result { #[test] fn a_3d_object_becomes_a_selected_group_of_stroked_edges_in_one_undo_step() { - for effect in ["threeD.extrude", "threeD.classicExtrude", "threeD.revolve", "threeD.inflate", "threeD.rotate"] { + for effect in [ + "threeD.extrude", + "threeD.classicExtrude", + "threeD.revolve", + "threeD.classicRevolve", + "threeD.inflate", + "threeD.rotate", + "threeD.classicRotate", + ] { let (mut s, id) = setup(Some(effect)); std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().name = Some("Box".into()); let before = s.doc().unwrap().doc.clone(); diff --git a/docs/mcp.md b/docs/mcp.md index a1d1f60c2f..10e3905982 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3228,8 +3228,8 @@ checked as that effect's. It is one undo step. Any other `threeDType` value, or ## Expand as Wireframes `effect.expandAsWireframes {ids?}` (**Window › 3D and Materials** › Quick Actions › Expand as Wireframes) replaces -each selected object, or each object in `ids`, that has a visible 3D effect (Extrude & Bevel, Extrude & Bevel -(Classic), Revolve, Inflate or Rotate, on the object or on its fills and strokes) with a group of paths: the edges of +each selected object, or each object in `ids`, that has a visible 3D effect (Extrude & Bevel, Revolve, Inflate, +Rotate or one of their 3D (Classic) forms, on the object or on its fills and strokes) with a group of paths: the edges of the faces the effect draws for the object as it is turned and projected now ([three-d.md](three-d.md#3d-and-materials-panel)). Each edge appears once; edges that meet end to end form one path, closed where it comes back to its start, and curved edges stay curves. Geometry effects after a 3D effect, diff --git a/docs/three-d.md b/docs/three-d.md index ca21b815e2..820bc1e805 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -52,7 +52,7 @@ pages describe it. - Each change is one undo step. Dragging a value or a slider previews it on the artboard and records one step when the pointer is released; a typed value is recorded when it is entered. Drawing the panel records nothing. - **Quick Actions › Expand as Wireframes** sits under the Object tab (under the first tab for Revolve and Rotate, - which have no Object tab). It replaces each selected object that has a 3D effect with a group of paths, the + which have no Object tab). It replaces each selected object that has a 3D effect, the 3D (Classic) effects included, with a group of paths, the edges of the faces the effect draws for the object as it is turned and projected now (`effect.expandAsWireframes`), as the public [Render 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/render-3d-vector-artwork.html) page describes for converting the wireframe into editable paths. Each edge appears once: edges that meet end to From 97eecdb21e49dfd789000000d887b53bdc3fe2a8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:37:07 -0800 Subject: [PATCH 19/92] Stroke: a group used as a clipping path adds nothing to a custom arrowhead A custom arrowhead takes a clipping path's own fills and strokes only where the clipping path paints them on the canvas. A group used as the clipping path paints nothing, so its members' fills no longer widen the head past the clipped art. --- crates/engine/src/cmd/arrowheads.rs | 15 ++++++++++++--- crates/engine/src/tests/arrowheads.rs | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/arrowheads.rs b/crates/engine/src/cmd/arrowheads.rs index 4dc58f6c8e..0aeda59731 100644 --- a/crates/engine/src/cmd/arrowheads.rs +++ b/crates/engine/src/cmd/arrowheads.rs @@ -222,7 +222,8 @@ impl Shapes { /// their strokes' outlines, its groups' members included (baking has already made a group's own /// fills and strokes art among its members), and the areas its gradient meshes cover. A clip /// group's clipped art is clipped to -/// its clipping path, whose own fill and stroke paint unclipped, as on the canvas. Hidden or fully +/// its clipping path, whose own fill and stroke paint unclipped, as on the canvas; a group used as +/// the clipping path paints nothing ([`clip_paint`]). Hidden or fully /// transparent art is left out. fn painted(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &Node, out: &mut Shapes) { if !n.visible || n.opacity <= 0.0 || out.too_detailed { @@ -239,7 +240,7 @@ fn painted(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &Node, out } NodeKind::Group { children, clip: true } => { let Some((clip, rest)) = children.split_first() else { return }; - painted(d, brushes, clip, out); + clip_paint(clip).iter().for_each(|part| painted(d, brushes, part, out)); // Nothing to clip by hides the clipped art, as on the canvas. let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(clip) else { return }; let mut inside = Shapes { anchors: out.anchors, ..Default::default() }; @@ -253,6 +254,14 @@ fn painted(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &Node, out } } +/// What clipping path `clip` paints ([`Node::clip_paint`]): its fills and its strokes, each as a +/// copy of it keeping only those. A group or another object that does not paint as a clipping +/// path gives neither. +fn clip_paint(clip: &Node) -> Vec { + let paint = clip.clip_paint(); + [paint.fill, paint.stroke].into_iter().flatten().collect() +} + /// The areas the shown paths and gradient meshes in `n` (an outlined stroke) fill, clipped by its /// clip groups, whose clipping paths' own fills paint unclipped. A stroke with a gradient along or /// across it is outlined as gradient meshes clipped to the stroke's outline. @@ -264,7 +273,7 @@ fn fills_in(n: &Node, out: &mut Shapes) { NodeKind::Mesh(_) => mesh_area(n, out), NodeKind::Group { children, clip: true } => { let Some((clip, rest)) = children.split_first() else { return }; - fills_in(clip, out); + clip_paint(clip).iter().for_each(|part| fills_in(part, out)); let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(clip) else { return }; let mut inside = Shapes { anchors: out.anchors, ..Default::default() }; rest.iter().for_each(|c| fills_in(c, &mut inside)); diff --git a/crates/engine/src/tests/arrowheads.rs b/crates/engine/src/tests/arrowheads.rs index 6b31fbcfda..b350341767 100644 --- a/crates/engine/src/tests/arrowheads.rs +++ b/crates/engine/src/tests/arrowheads.rs @@ -348,3 +348,18 @@ fn a_symbol_whose_shapes_cannot_be_combined_is_skipped() { let skipped: Vec<&str> = read.skipped.iter().map(|(n, _)| n.as_str()).collect(); assert_eq!(skipped, ["Broken", "Broken Clip"], "{:?}", read.skipped); } + +#[test] +fn a_group_used_as_the_clipping_path_adds_none_of_its_own_fills() { + // A group holding a filled 20 × 4 bar clips a filled 4 × 4 square: the group paints nothing as + // the clipping path, so the head is the square alone. + let bar = polygon(&[(0.0, 0.0), (20.0, 0.0), (20.0, 4.0), (0.0, 4.0)], filled()); + let mask = Node::group(NodeId(0), vec![Arc::new(bar)]); + let square = polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], filled()); + let mut d = Document::new(100.0, 100.0); + add_symbol(&mut d, "Group Clip", Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(square)], clip: true })); + let read = heads_of(&d); + assert_eq!(read.heads.len(), 1, "{:?}", read.skipped); + let b = read.heads[0].path.bounds().unwrap(); + assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "the square alone: {b:?}"); +} From ac79ae55ba055830d6a9372eb2071bba80a57795 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:52:39 -0800 Subject: [PATCH 20/92] 3D and Materials: Expand as Wireframes keeps a layer's ruler guides and reshapes each face - Expanding a layer removed its ruler guides with its members. The guides of the layer and of its sublayers now stay on the layer, after the edges. - Geometry effects after a 3D effect reshaped all the faces as one path, so Offset Path merged faces that touch. Each effect now reshapes each face on its own, about the bounds of all the faces, within one shared budget of anchors, as it reshapes the drawn surface. Docs: docs/mcp.md. --- crates/effects/src/solids/wireframe.rs | 34 ++++++++++++-------- crates/effects/src/solids/wireframe/tests.rs | 13 ++++++++ crates/engine/src/cmd/wireframe.rs | 27 ++++++++++++++-- crates/engine/src/cmd/wireframe/tests.rs | 20 ++++++++++++ docs/mcp.md | 2 +- 5 files changed, 80 insertions(+), 16 deletions(-) diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs index e2656222b3..5f2275d8eb 100644 --- a/crates/effects/src/solids/wireframe.rs +++ b/crates/effects/src/solids/wireframe.rs @@ -1,8 +1,10 @@ //! Expand as Wireframes: the edges of the faces a 3D effect draws, as open and closed paths that //! can be edited on their own. use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::Arc; -use vectorcraft_doc::{AppearanceItem, Document, Effect, Node}; +use vectorcraft_doc::color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId}; use vectorcraft_geom::{Anchor, CubicBez, PathData, Point, SubPath}; use vectorcraft_three_d::Facet; @@ -10,7 +12,7 @@ use super::{ INFLATE, Profile, ROTATE, facets, geometry, has_solid, inflate_options, is_container, is_plane, is_three_d, label_of, members, object_profile, region, reshape_members, resolve, solid, solid_paints, solid_parts, union_bounds, view_options, }; -use crate::{GeomContext, apply_geometry_with, is_geometry}; +use crate::{GeomContext, is_geometry}; /// The most distinct edges one object's wireframe may have. pub(crate) const MAX_WIRE_EDGES: usize = 1_000_000; @@ -50,7 +52,7 @@ pub fn three_d_wireframe_in(doc: Option<&Document>, n: &Node) -> Result, n: &Node) -> Result) -> Vec { +/// `faces` reshaped by the visible geometry effects after `n`'s own 3D effect ([`reshaped`]). +fn after_geometry(doc: Option<&Document>, n: &Node, faces: Vec) -> Vec { let fx = &n.appearance.effects; let Some(index) = fx.iter().position(|e| e.visible && is_three_d(&e.id)) else { return faces }; - reshaped(faces, fx.get(index + 1..).unwrap_or_default(), &GeomContext::of(n)) + reshaped(faces, fx.get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..GeomContext::of(n) }) } -/// `faces` reshaped by the visible geometry effects in `later` (the effects after a 3D effect), -/// which act on the faces as a whole, about their bounds, as they act on the drawn surface. +/// `faces` reshaped by the visible geometry effects in `later` (the effects after a 3D effect), as +/// they reshape the faces of the drawn surface ([`crate::reshape::reshape_leaves`]): each effect +/// reshapes each face on its own, about the bounds all the faces have before it, and the faces keep +/// to one budget of anchors together. `ctx` gives the object's id, the seed of a random Transform, +/// and the document. pub(crate) fn reshaped(faces: Vec, later: &[Effect], ctx: &GeomContext) -> Vec { - let after: Vec = later.iter().filter(|e| e.visible && is_geometry(&e.id)).cloned().collect(); + let after: Vec<&Effect> = later.iter().filter(|e| e.visible && is_geometry(&e.id)).collect(); if after.is_empty() { return faces; } - let joined = PathData::new(faces.into_iter().flat_map(|f| f.subpaths).collect()); - let Some(bounds) = joined.bounds() else { return vec![joined] }; - vec![apply_geometry_with(&after, &joined, bounds, ctx)] + let id = NodeId(ctx.seed); + let fill = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0); + let mut art = Node::group(id, faces.into_iter().map(|f| Arc::new(Node::path(id, f, fill.clone()))).collect()); + crate::reshape::reshape_leaves(&mut art, &after, later, ctx.doc); + let mut out = vec![]; + art.walk(&mut |c| out.extend(c.path_data().cloned())); + out } /// The outline of every face [`super::solid_art`] draws for `n` (already resolved): with the effect diff --git a/crates/effects/src/solids/wireframe/tests.rs b/crates/effects/src/solids/wireframe/tests.rs index 8498885776..158e42d98f 100644 --- a/crates/effects/src/solids/wireframe/tests.rs +++ b/crates/effects/src/solids/wireframe/tests.rs @@ -157,6 +157,19 @@ fn revolve_classic_and_rotate_classic_expand_too() { assert_eq!(segments(&plane).len(), 4); } +/// The geometry effects after a 3D effect reshape each face on its own, as they reshape each face +/// of the drawn surface: two faces that touch stay two outlines. +#[test] +fn later_geometry_effects_reshape_each_face_on_its_own() { + let n = solid_square(super::super::EXTRUDE, json!({})); + let fx = [crate::new_effect("path.offsetPath", &json!({"offset": 1.0})).unwrap()]; + let out = reshaped(vec![square(0.0, 0.0, 10.0), square(10.0, 0.0, 10.0)], &fx, &GeomContext::of(&n)); + assert_eq!(out.len(), 2, "{out:?}"); + let (a, b) = (out[0].bounds().unwrap(), out[1].bounds().unwrap()); + assert!((a.x0 + 1.0).abs() < 1e-3 && (a.x1 - 11.0).abs() < 1e-3, "{a:?}"); + assert!((b.x0 - 9.0).abs() < 1e-3 && (b.x1 - 21.0).abs() < 1e-3, "{b:?}"); +} + /// A geometry effect after a fill's 3D effect moves that fill's edges, as it moves its drawn surface. #[test] fn a_geometry_effect_after_a_fill_s_3d_effect_reshapes_its_edges() { diff --git a/crates/engine/src/cmd/wireframe.rs b/crates/engine/src/cmd/wireframe.rs index 42dfaf8a33..ccd82dd0c3 100644 --- a/crates/engine/src/cmd/wireframe.rs +++ b/crates/engine/src/cmd/wireframe.rs @@ -25,7 +25,7 @@ pub fn specs() -> Vec { "Expand as Wireframes", [], None, - "{ids?: [..] (default: the selected objects)} replace each object that has a visible 3D effect (Extrude & Bevel, Revolve, Inflate, Rotate or one of their 3D (Classic) forms, on the object or on its fills and strokes) with a group of paths: the edges of the faces the effect draws, as the object is turned and projected now, each edge once (edges that meet end to end make one path, closed where it comes back to its start; curved edges stay curves; geometry effects after the 3D effect, on the object or on a fill or stroke, reshape them too), stroked 0.5 pt black with round joins and no fill (VectorCraft's own); the group takes the object's place, id, name, opacity, blending mode, opacity mask, attributes, slice and text wrap and is selected (a layer stays a layer holding the edges, which are selected); objects without a visible 3D effect are left as they are; one undo step. An error, with nothing changed, when no object is selected or given, when none has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges → {ids}", + "{ids?: [..] (default: the selected objects)} replace each object that has a visible 3D effect (Extrude & Bevel, Revolve, Inflate, Rotate or one of their 3D (Classic) forms, on the object or on its fills and strokes) with a group of paths: the edges of the faces the effect draws, as the object is turned and projected now, each edge once (edges that meet end to end make one path, closed where it comes back to its start; curved edges stay curves; geometry effects after the 3D effect, on the object or on a fill or stroke, reshape them too), stroked 0.5 pt black with round joins and no fill (VectorCraft's own); the group takes the object's place, id, name, opacity, blending mode, opacity mask, attributes, slice and text wrap and is selected (a layer stays a layer holding the edges, which are selected, and its ruler guides); objects without a visible 3D effect are left as they are; one undo step. An error, with nothing changed, when no object is selected or given, when none has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges → {ids}", has_doc, expand_as_wireframes )] @@ -38,6 +38,25 @@ fn wire_appearance() -> Appearance { Appearance { items: vec![AppearanceItem::Stroke(stroke)], ..Default::default() } } +/// The ruler guides of layer `n` and then those of the sublayers inside it, in order, added to +/// `out`. A guide from a hidden or locked sublayer, or from one inside a hidden or locked group or +/// sublayer, is hidden or locked itself. +fn ruler_guides(n: &Node, hidden: bool, locked: bool, out: &mut Vec>) { + let Some(children) = n.children() else { return }; + for c in children.iter().filter(|c| c.is_ruler_guide()) { + let mut g = c.clone(); + if hidden || locked { + let g = Arc::make_mut(&mut g); + g.visible &= !hidden; + g.locked |= locked; + } + out.push(g); + } + for c in children.iter().filter(|c| !c.is_ruler_guide()) { + ruler_guides(c, hidden || !c.visible, locked || c.locked, out); + } +} + fn expand_as_wireframes(s: &mut Session, p: &Value) -> Result { let roots = checked_ids_or_selection(s, p, C)?; if roots.is_empty() { @@ -70,9 +89,13 @@ fn expand_as_wireframes(s: &mut Session, p: &Value) -> Result { let Some(source) = d.node(id) else { continue }; if matches!(source.kind, NodeKind::Layer { .. }) { // A layer stays a layer: its members become the edges, and its 3D effect is removed. + // Its ruler guides and those of its sublayers are not art: they stay, after the edges. let mut layer = source.clone(); - let children: Vec> = wire.into_iter().map(|path| Arc::new(Node::path(d.alloc_id(), path, wire_appearance()))).collect(); + let mut guides = vec![]; + ruler_guides(source, false, false, &mut guides); + let mut children: Vec> = wire.into_iter().map(|path| Arc::new(Node::path(d.alloc_id(), path, wire_appearance()))).collect(); selected.extend(children.iter().map(|c| c.id)); + children.extend(guides); if let Some(members) = layer.children_mut() { *members = children; } diff --git a/crates/engine/src/cmd/wireframe/tests.rs b/crates/engine/src/cmd/wireframe/tests.rs index ce881982cd..ab51c81282 100644 --- a/crates/engine/src/cmd/wireframe/tests.rs +++ b/crates/engine/src/cmd/wireframe/tests.rs @@ -92,6 +92,26 @@ fn a_layer_with_a_3d_effect_stays_a_layer_holding_the_edges() { assert_eq!(undo_len(&s), history + 1); } +/// A layer's ruler guides are not art: they stay on the layer, after the edges, and are not selected. +#[test] +fn a_layer_keeps_its_ruler_guides() { + let (mut s, _) = setup(None); + let layer = s.doc().unwrap().doc.layers[0].id; + s.execute("guide.add", &json!({"vertical": true, "pos": 50})).unwrap(); + let guides = |s: &Session| -> Vec { + s.doc().unwrap().doc.node(layer).unwrap().children().unwrap().iter().filter(|c| c.is_ruler_guide()).map(|c| c.id).collect() + }; + let before = guides(&s); + assert_eq!(before.len(), 1); + s.execute("effect.apply", &json!({"ids":[layer.0],"effect":"threeD.extrude","item":null,"params":{}})).unwrap(); + run(&mut s, json!({"ids":[layer.0]})).unwrap(); + assert_eq!(guides(&s), before); + let st = s.doc().unwrap(); + let children = st.doc.node(layer).unwrap().children().unwrap(); + assert!(children.len() > 1 && children.last().is_some_and(|c| c.is_ruler_guide())); + assert!(!st.selection.objects.is_empty() && st.selection.objects.iter().all(|id| !before.contains(id))); +} + /// The number of segments in the wireframe group `id`. fn segments(s: &Session, id: NodeId) -> usize { let st = s.doc().unwrap(); diff --git a/docs/mcp.md b/docs/mcp.md index 10e3905982..7457e6faa3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3237,7 +3237,7 @@ on the object or on a fill or stroke, reshape its edges as they reshape the draw black stroke with round joins and no fill (VectorCraft's own choice). The group keeps the object's place, id, name, visibility, lock, opacity, blending mode, knockout, opacity mask, attributes, slice and text wrap, and the groups are selected. -A layer in `ids` stays a layer: its members are replaced by the edges, which are selected. Objects without a visible 3D effect do not change. It is one undo step and +A layer in `ids` stays a layer: its members are replaced by the edges, which are selected, and its ruler guides stay. Objects without a visible 3D effect do not change. It is one undo step and returns `{ids}`, the groups. It is an error, and nothing changes, when nothing is selected or given, when no object has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges. From a0902005f7273da9c3e280e8527648b59cdacbc0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:00:56 -0800 Subject: [PATCH 21/92] Blend: the setEasing docs list the easing types and say when a blend made without a spine takes easing docs/mcp.md lists the four easingType values. It and the object.blend.setEasing params text say that a blend made without a spine takes easingType and ramp once Replace Spine has given it one, which the set_easing test now checks. --- crates/engine/src/cmd/live/commands.rs | 2 +- crates/engine/src/tests/blendopts.rs | 6 ++++++ docs/mcp.md | 7 ++++--- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index 4cbc5e130f..980983519f 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Blend Easing", [], None, - "{id? (default: the first selected blend), easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100)} set one blend's easing and color shift, as the blend widgets on the canvas do; omitted ones keep their value. easingType and ramp need a blend whose steps follow a spine (a replaced or edited spine, or Align to Path, on a blend not made without a spine); out of range, a hidden or locked blend and a blend with no spine are errors. A value that changes nothing records no undo step → {id, easingType, ramp, colorShift, changed}", + "{id? (default: the first selected blend), easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100)} set one blend's easing and color shift, as the blend widgets on the canvas do; omitted ones keep their value. easingType and ramp need a blend whose steps follow a spine (a replaced or edited spine, or Align to Path); a blend made without a spine takes them once Replace Spine has given it one; out of range, a hidden or locked blend and a blend with no spine are errors. A value that changes nothing records no undo step → {id, easingType, ramp, colorShift, changed}", has_doc, blend_set_easing ), diff --git a/crates/engine/src/tests/blendopts.rs b/crates/engine/src/tests/blendopts.rs index a71e835108..93f531d1e8 100644 --- a/crates/engine/src/tests/blendopts.rs +++ b/crates/engine/src/tests/blendopts.rs @@ -481,6 +481,12 @@ fn set_easing_edits_one_blend_and_refuses_what_it_cannot_set() { assert_eq!(depth(&s), d3); s.execute("object.blend.setEasing", &json!({"id": n.0, "colorShift": 10})).unwrap(); assert_eq!(spec(&s, n).1.color_shift, 10.0); + // Replace Spine gives it a spine, and it takes easing from then on. + let path = id_of(&s.execute("path.create", &json!({"d": "M0 320 L200 320"})).unwrap()); + sel(&mut s, &[n, path]); + s.execute("object.blend.replaceSpine", &json!({})).unwrap(); + let r = s.execute("object.blend.setEasing", &json!({"id": n.0, "easingType": "easeOut", "ramp": 30})).unwrap(); + assert_eq!((&r["easingType"], &r["ramp"], &r["changed"]), (&json!("easeOut"), &json!(30.0), &json!(true))); } /// Dragging a blend widget with the Selection tool or the Blend tool is one undo step; a click, a diff --git a/docs/mcp.md b/docs/mcp.md index 2e4536c069..05ec24d69e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8754,9 +8754,10 @@ so its point at `t` moves by (dx, dy), turning a straight spine into a curve (`p `object.blend.setEasing {id?, easingType?, ramp?, colorShift?}` sets one blend's easing and color shift (the first selected blend without `id`) and returns `{id, easingType, ramp, colorShift, changed}`; omitted values are kept. -`ramp` is 0 to 100 and `colorShift` -100 to 100. `easingType` and `ramp` need a blend whose steps follow a spine (a -replaced or edited spine, or `orientation: "path"`) and not made without one (`createSpine: "never"`), as the Blend -panel's Type and Ramp do. A value out of range, a hidden or locked blend and a target that is not a blend are errors, +`easingType` is `easeIn`, `easeOut`, `easeInOut` or `easeOutIn`, `ramp` is 0 to 100 and `colorShift` -100 to 100. +`easingType` and `ramp` need a blend whose steps follow a spine (a replaced or edited spine, or +`orientation: "path"`), as the Blend panel's Type and Ramp do. A blend made without a spine +(`createSpine: "never"`) takes them once Replace Spine has given it one. A value out of range, a hidden or locked blend and a target that is not a blend are errors, and a call that changes nothing records no undo step (`changed: false`). With one blend selected that has a spine, the Selection and Blend tools show the same values as widgets 16 px above and below the spine's halfway point (where the boxes of three or more other visible objects hold that From baa7ada7a13df33fccec8f7151be6e4dcc862f67 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:05:56 -0800 Subject: [PATCH 22/92] Automation: errors name a path outside the read roots by its file name Under --automation-read-root, an error about a file or folder the client did not give now names it whole only when it is inside the read roots. Outside them it names the file or folder by its file name, or as "a file or folder outside the read roots" when it has none, and keeps the roots and the reason. A path the client gives stays whole. This covers a save without a path (the document's own file and the files written beside it, also inside action.batch, and the refusal to write over a file read with losses), file.revert, script.listInstalled, plugin.reload without a path and plugin.list's folder report, and links.relink with copy (the document's folder, its error when the document's path has no folder, and the copies in the reply). Export for Screens' refusal for an export preset's folder uses the same helper. --- crates/engine/src/cmd/fileio.rs | 3 +- crates/engine/src/cmd/fileio/access.rs | 89 ++++++++++++++++++++ crates/engine/src/cmd/fileio/action_batch.rs | 36 ++++++++ crates/engine/src/cmd/fileio/save.rs | 14 ++- crates/engine/src/cmd/fileio/screens.rs | 42 +-------- crates/engine/src/cmd/links.rs | 26 ++++-- crates/engine/src/cmd/plugin.rs | 43 ++++++++-- crates/engine/src/cmd/scripts.rs | 3 +- crates/engine/src/tests/links.rs | 50 +++++++++++ crates/engine/src/tests/plugins.rs | 31 +++++++ crates/engine/src/tests/save.rs | 49 +++++++++++ crates/engine/src/tests/script_folder.rs | 4 +- docs/mcp.md | 6 ++ docs/target-app-parity.md | 1 + 14 files changed, 342 insertions(+), 55 deletions(-) diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 329988cb34..e7dc59a951 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -127,7 +127,8 @@ pub use access::write_file_with; #[cfg(not(target_arch = "wasm32"))] pub(crate) use access::{Placed, copy_new, file_metadata}; pub(crate) use access::{ - absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, shown_link, shown_path, write_file, write_new_file, + absolute_path, create_dir, document_path, file_created, file_stamp, naming_in_error, read_file, read_file_head, shown_link, shown_path, + write_file, write_new_file, }; #[cfg(all(test, windows))] pub(crate) use access::{document_path_on, document_path_with}; diff --git a/crates/engine/src/cmd/fileio/access.rs b/crates/engine/src/cmd/fileio/access.rs index 7b20326106..14251dee28 100644 --- a/crates/engine/src/cmd/fileio/access.rs +++ b/crates/engine/src/cmd/fileio/access.rs @@ -65,6 +65,50 @@ pub(crate) fn shown_link<'a>(stored: &'a str, resolved: Option<&str>) -> std::bo std::borrow::Cow::Borrowed(if matches!(name, "." | "..") { "" } else { name }) } +/// What an error names a file or folder outside the read roots by when [`shown_path`] gives no +/// name for it, or gives one that is not valid text. +pub(crate) const UNNAMED: &str = "a file or folder outside the read roots"; + +/// An error about `path`, a file or folder the client did not give, as the request being served +/// may see it: where the message names `path` ([`naming_in_message`]), it names it as +/// [`shown_path`] does, or as [`UNNAMED`] when that is empty or holds a replacement character. The +/// roots the message names and the reason stay whole. Unchanged when `path` is [`shown`]. +pub(crate) fn naming_in_error(e: EngineError, path: &str) -> EngineError { + if path.is_empty() || shown(path) { + return e; + } + let name = shown_path(path); + let name = if name.is_empty() || name.contains('\u{FFFD}') { UNNAMED } else { &name }; + match e { + EngineError::Other(msg) => EngineError::Other(naming_in_message(&msg, path, name)), + EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: naming_in_message(&msg, path, name) }, + e => e, + } +} + +/// `msg` with `path` given as `name` where it begins a path the message is about: at the start of +/// `msg` (`: `), after a `: ` (`... outside the write root : `) or after +/// `cannot resolve `, followed by the end, a separator, a `:` or a space. A root the message names +/// stays whole, even one inside `path`. +pub(crate) fn naming_in_message(msg: &str, path: &str, name: &str) -> String { + const BEFORE: [&str; 2] = [": ", "cannot resolve "]; + let mut out = String::with_capacity(msg.len()); + let mut rest = msg; + loop { + if let Some(after) = rest.strip_prefix(path) + && after.chars().next().is_none_or(|c| matches!(c, '/' | '\\' | ':' | ' ')) + { + out.push_str(name); + rest = after; + } + let Some((i, len)) = BEFORE.iter().filter_map(|b| rest.find(b).map(|i| (i, b.len()))).min() else { break }; + out.push_str(&rest[..i + len]); + rest = &rest[i + len..]; + } + out.push_str(rest); + out +} + /// `path` checked for writing against the automation roots in force. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn may_write(path: &str) -> Result<()> { @@ -441,3 +485,48 @@ pub(crate) fn create_dir(path: &str) -> Result<()> { pub(crate) fn write_new_file(path: &str, _: &[u8]) -> Result<()> { Err(no_fs(path)) } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::*; + use crate::file_access::{AutomationRoots, confine}; + + /// An error about a path the client did not give, under roots that don't let it be read: the + /// path is named by its file name (after a `/` or a `\`), or as [`UNNAMED`] when it ends in no + /// name or its name is not valid text, and every root and the reason stay whole. A path inside the + /// read roots, and any path without roots in force, stay whole. + #[test] + fn an_error_names_a_path_outside_the_read_roots_by_its_name() { + let base = std::env::temp_dir().join(format!("vc-access-naming-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (a, b, out) = (base.join("a"), base.join("b"), base.join("kept-out")); + for f in [&a, &b, &out] { + std::fs::create_dir_all(f).unwrap(); + } + let text = |p: &std::path::Path| p.to_string_lossy().into_owned(); + let roots = AutomationRoots::new(&[a.as_path(), b.as_path()], &[a.as_path(), b.as_path()]).unwrap(); + let file = text(&out.join("doc.svg")); + let raw = confine(roots.as_ref(), || crate::file_access::check_write(&file)).unwrap_err(); + let shown_root = |p: &std::path::Path| crate::cmd::findfiles::plain(std::fs::canonicalize(p).unwrap()).display().to_string(); + let (ra, rb) = (shown_root(&a), shown_root(&b)); + let named = |e: EngineError, path: &str| confine(roots.as_ref(), || naming_in_error(e, path)).to_string(); + + assert_eq!(named(EngineError::Other(raw.clone()), &file), format!("automation path rejected: outside the write roots {ra}, {rb}: doc.svg")); + let io = named(EngineError::Other(format!("{file}: disk full")), &file); + assert_eq!(io, "doc.svg: disk full"); + let bad = named(EngineError::BadParams { cmd: "x".into(), msg: format!("{file}: not a file") }, &file); + assert_eq!(bad, "invalid parameters for `x`: doc.svg: not a file"); + // A Windows path written with `\`, a path that ends in no name and a name that isn't text. + let win = r"C:\Users\me\secret\doc.svg"; + assert_eq!(named(EngineError::Other(format!("{win}: denied")), win), "doc.svg: denied"); + let folder = format!("{}/", text(&out)); + assert_eq!(named(EngineError::Other(format!("{folder}: denied")), &folder), format!("{UNNAMED}: denied")); + let lossy = text(&out.join("bad\u{FFFD}.svg")); + assert_eq!(named(EngineError::Other(format!("{lossy}: denied")), &lossy), format!("{UNNAMED}: denied")); + // Readable, or nothing confined: whole. + let inside = text(&a.join("x.svg")); + assert_eq!(named(EngineError::Other(format!("{inside}: denied")), &inside), format!("{inside}: denied")); + assert_eq!(naming_in_error(EngineError::Other(raw.clone()), &file).to_string(), raw); + let _ = std::fs::remove_dir_all(base); + } +} diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 4953785189..232d5ba1f4 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -1429,6 +1429,42 @@ mod tests { assert_eq!(written.len(), 2, "{written:?}"); } + /// A Data Sets batch whose Save step has no path saves the document's own file. When that file is + /// outside the client's read and write roots, each row's error keeps the write root and names the + /// file by its file name, with Stop on Error on and off. A source folder the client gives stays + /// whole in the error. + #[test] + fn dataset_batch_step_error_hides_document_path() { + use crate::file_access::{AutomationRoots, confine}; + + let base = tmp("roots-error"); + let (inside, outside) = (base.join("in"), base.join("kept-out")); + for f in [&inside, &outside] { + std::fs::create_dir_all(f).unwrap(); + } + let mut s = with_data_sets(&["Alpha", "Beta"]); + let own = outside.join("doc.vectorcraft").to_string_lossy().into_owned(); + s.execute("document.save", &json!({"path": own})).unwrap(); + let roots = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap(); + let root = crate::cmd::findfiles::plain(std::fs::canonicalize(&inside).unwrap()).display().to_string(); + let hidden = outside.to_string_lossy().into_owned(); + for (stop, rows) in [(true, 1), (false, 2)] { + let p = json!({"source": "datasets", "stopOnError": stop, "commands": [{"command": "document.save", "params": {}}]}); + let r = confine(roots.as_ref(), || s.execute("action.batch", &p)).unwrap(); + assert_eq!((r["failed"].as_u64(), r["processed"].as_array().map(Vec::len)), (Some(rows), Some(rows as usize)), "{r}"); + for row in r["processed"].as_array().unwrap() { + let e = row["error"].as_str().unwrap_or_default(); + let want = format!("batch step 0 (`document.save`) failed: automation path rejected: outside the write root {root}: doc.vectorcraft"); + assert_eq!(e, want, "{r}"); + } + assert!(!r.to_string().contains(&hidden), "{r}"); + } + let p = json!({"source": "folder", "folder": hidden, "commands": [{"command": "object.unlockAll", "params": {}}]}); + let e = confine(roots.as_ref(), || s.execute("action.batch", &p)).unwrap_err().to_string(); + assert!(e.contains(&hidden), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + /// The names of the files in `dir`, sorted. fn file_names(dir: &std::path::Path) -> Vec { let mut names: Vec = diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 5d7783665b..0fa346dd5b 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -298,7 +298,9 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { }; let modified = date_param(p, "modified", cmd)?; if let Some(path) = &path { - super::check_not_lossy_overwrite(st, path, p, cmd)?; + // Without a path, it is the document's own file: the error names it as the request may see it. + let given = str_param(p, "path").is_some_and(|g| !g.is_empty()); + super::check_not_lossy_overwrite(st, path, p, cmd).map_err(|e| if given { e } else { super::access::naming_in_error(e, path) })?; } let written = written_as(cmd, format, &Value::Object(options.clone())); let top = NATIVE_OPTIONS.iter().filter(|k| p.get(**k).is_some_and(|v| !v.is_null())).map(|k| k.to_string()); @@ -1043,7 +1045,12 @@ pub(crate) fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result let plan = save_plan(s, mode, p)?; let job = save_job(s, plan)?; let uid = s.doc()?.uid; - let mut out = job.write_streamed(&mut super::write_file_with)?; + let given = str_param(p, "path").is_some_and(|g| !g.is_empty()); + // Without a path, the files written are the document's own file and those beside it. + let mut write = |path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| { + super::write_file_with(path, fill).map_err(|e| if given { e } else { super::access::naming_in_error(e, path) }) + }; + let mut out = job.write_streamed(&mut write)?; job.complete(s, uid); leave_out_unreadable_save_paths(&mut out, str_param(p, "path")); Ok(out) @@ -1098,7 +1105,8 @@ fn can_revert(s: &Session) -> std::result::Result<(), String> { fn revert(s: &mut Session, _: &Value) -> Result { let index = s.active_index().ok_or(crate::EngineError::NoDocument)?; let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?; - let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs); + let bytes = read_file(&path).map_err(|e| super::access::naming_in_error(e, &path))?; + let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &bytes)?.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs); doc.template = false; // Back to the saved file: its recovery copy has nothing left to recover. let uid = s.doc()?.uid; diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index b48aef68c3..b991dc0bec 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -649,7 +649,7 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { let p = with_preset(s, p)?; let hidden = if given { None } else { str_param(&p, "folder").filter(|f| !f.is_empty() && !super::access::shown(f)) }; let mut r = export(s, &p).map_err(|e| match hidden { - Some(folder) => leave_out_folder_from_error(e, folder), + Some(folder) => super::access::naming_in_error(e, folder), None => e, })?; remember(s, &p); @@ -659,41 +659,6 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { Ok(r) } -/// An error of an export into `folder`, a folder outside the read roots that the client did not -/// give: `folder` as the path the error is about names it by its own name -/// ([`super::access::shown_path`]), and the rest of the message stays. -fn leave_out_folder_from_error(e: EngineError, folder: &str) -> EngineError { - let name = super::access::shown_path(folder); - match e { - EngineError::Other(msg) => EngineError::Other(leave_out_folder_from_message(&msg, folder, &name)), - EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: leave_out_folder_from_message(&msg, folder, &name) }, - e => e, - } -} - -/// `msg` with `folder` given as `name` where it begins a path the message is about: at the start -/// of `msg` (`: `), after a `: ` (`... outside the write root : `) or -/// after `cannot resolve `, followed by the end, a separator, a `:` or a space. A root the message -/// names stays whole, even one inside `folder`. -fn leave_out_folder_from_message(msg: &str, folder: &str, name: &str) -> String { - const BEFORE: [&str; 2] = [": ", "cannot resolve "]; - let mut out = String::with_capacity(msg.len()); - let mut rest = msg; - loop { - if let Some(after) = rest.strip_prefix(folder) - && after.chars().next().is_none_or(|c| matches!(c, '/' | '\\' | ':' | ' ')) - { - out.push_str(name); - rest = after; - } - let Some((i, len)) = BEFORE.iter().filter_map(|b| rest.find(b).map(|i| (i, b.len()))).min() else { break }; - out.push_str(&rest[..i + len]); - rest = &rest[i + len..]; - } - out.push_str(rest); - out -} - /// The reply `r` of an export into `folder`, a folder the client did not give, as the request being /// served may see it: when `folder` is outside the read roots ([`super::access::shown`]), the ZIP's /// `path` is left out and `files` names each file written relative to `folder` (`2x/Logo@2x.png`), @@ -1475,7 +1440,8 @@ pub(super) fn export_settings(s: &mut Session, _: &Value) -> Result { #[cfg(test)] mod tests { - use super::{Output, ScreenSize, leave_out_folder_from_message, safe_file_name}; + use super::super::access::naming_in_message; + use super::{Output, ScreenSize, safe_file_name}; /// A hidden export folder is named by its name where a message is about it, and a root that /// starts with the same text, or a longer path, stays whole. @@ -1492,7 +1458,7 @@ mod tests { ("automation path rejected: outside the write root /a: /work/outer", "automation path rejected: outside the write root /a: /work/outer"), ]; for (msg, want) in cases { - assert_eq!(leave_out_folder_from_message(msg, f, "out"), want, "{msg}"); + assert_eq!(naming_in_message(msg, f, "out"), want, "{msg}"); } } diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 89c0afefcf..43b7c3dfd4 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -68,7 +68,7 @@ pub fn specs() -> Vec { "Relink", [], None, - "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object in the layers linked to the same file as one of them) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy)}", + "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object in the layers linked to the same file as one of them) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy; to is the file name alone when the document's folder is outside the automation read roots)}", has_doc, relink ), @@ -1337,7 +1337,9 @@ fn relink(s: &mut Session, p: &Value) -> Result { (true, false) => return Err(bad(C, "copy needs path: the file to copy into the document's folder")), (true, true) => { let doc = st.path.as_deref().map(absolute_path).ok_or_else(|| bad(C, "save the document first: copy puts the file in its folder"))?; - Some(Path::new(&doc).parent().map(Path::to_path_buf).ok_or_else(|| bad(C, format!("{doc} has no folder")))?) + // The document's path is not the client's: the error names it as the request may see it. + let no_folder = || fileio::naming_in_error(bad(C, format!("{doc} has no folder")), &doc); + Some(Path::new(&doc).parent().map(Path::to_path_buf).ok_or_else(no_folder)?) } }; // An image or a placed document, with its link. @@ -1472,6 +1474,12 @@ fn relink(s: &mut Session, p: &Value) -> Result { not_found.dedup(); let mut out = json!({ "relinked": relinked, "notFound": not_found }); if copy_to.is_some() { + // The copies are in the document's folder, which the client did not give. + for pair in copied.iter_mut().chain(kept.iter_mut()) { + if let Some(to) = pair.get("to").and_then(Value::as_str).map(str::to_string) { + pair["to"] = json!(fileio::shown_path(&to)); + } + } out["copied"] = json!(copied); out["kept"] = json!(kept); } @@ -1521,9 +1529,14 @@ fn copy_into_folder(from: &str, bytes: &[u8], dir: &Path) -> Result<(String, boo use std::io::Write as _; let source = Path::new(from); let name = source.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - // Each name it takes is checked against the automation roots in force. + // Each name it takes is checked against the automation roots in force. The folder is the + // document's, so a refusal names it and the file as the request may see them. let create = |to: &Path| { - crate::file_access::check_write(&to.to_string_lossy()).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; + let to_text = to.to_string_lossy(); + crate::file_access::check_write(&to_text).map_err(|e| { + let e = fileio::naming_in_error(EngineError::Other(e), &to_text); + std::io::Error::new(std::io::ErrorKind::PermissionDenied, e.to_string()) + })?; vectorcraft_format::write_new_with(to, |f| f.write_all(bytes)) }; // A file there that automation may not read isn't taken for the same file. @@ -1534,7 +1547,10 @@ fn copy_into_folder(from: &str, bytes: &[u8], dir: &Path) -> Result<(String, boo Ok(Some(fileio::Placed::Copied(to))) => Ok((to.to_string_lossy().into_owned(), true)), Ok(Some(fileio::Placed::Kept(to))) => Ok((to.to_string_lossy().into_owned(), false)), Ok(None) => Err(EngineError::Other(format!("every numbered name for {name} is taken in the document's folder"))), - Err(e) => Err(EngineError::Other(format!("{}: {e}", dir.display()))), + Err(e) => { + let dir = dir.to_string_lossy(); + Err(fileio::naming_in_error(EngineError::Other(format!("{dir}: {e}")), &dir)) + } } } diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 2d16bbc683..04a065ba1b 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -917,12 +917,43 @@ fn reload(s: &mut Session, p: &Value) -> Result { if folder.is_empty() { return Err(bad(C, "no folder: pass `path` or set Preferences › Plug-ins & Scratch Disks › Additional Plug-ins Folder")); } + let given = p.get("path").is_some(); let report = load_folder(s, &folder); - if let Some(e) = report.get("error").and_then(Value::as_str) { + let shown = if given { report.clone() } else { report_as_shown(report.clone()) }; + if let Some(e) = shown.get("error").and_then(Value::as_str) { return Err(EngineError::Other(e.to_string())); } - *FOLDER.lock().unwrap_or_else(|e| e.into_inner()) = Some((folder, s.plugin_dir.clone(), report.clone())); - Ok(report) + *FOLDER.lock().unwrap_or_else(|e| e.into_inner()) = Some((folder, s.plugin_dir.clone(), report)); + Ok(shown) +} + +/// The report of a plug-in folder load as the request being served may see it: the folder and each +/// file it names that is outside the read roots is named by its file name +/// ([`super::fileio::shown_path`]), also in the errors ([`super::fileio::naming_in_error`]). +fn report_as_shown(mut report: Value) -> Value { + use super::fileio::{naming_in_error, shown_path}; + + if crate::file_access::current().is_none() { + return report; + } + let naming = |msg: &str, path: &str| naming_in_error(EngineError::Other(msg.to_string()), path).to_string(); + let Some(o) = report.as_object_mut() else { return report }; + if let Some(folder) = o.get("folder").and_then(Value::as_str).map(str::to_string) { + if let Some(Value::String(e)) = o.get_mut("error") { + *e = naming(e, &folder); + } + o.insert("folder".into(), json!(shown_path(&folder))); + } + if let Some(Value::Array(rows)) = o.get_mut("failed") { + for row in rows.iter_mut().filter_map(Value::as_object_mut) { + let Some(file) = row.get("file").and_then(Value::as_str).map(str::to_string) else { continue }; + if let Some(Value::String(e)) = row.get_mut("error") { + *e = naming(e, &file); + } + row.insert("file".into(), json!(shown_path(&file))); + } + } + report } /// [`describe`] for an installed plug-in, with the bytes its cache holds (`cacheBytes`). @@ -933,7 +964,7 @@ fn describe_installed(p: &Plugin) -> Value { } fn list(_: &mut Session, _: &Value) -> Result { - let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| r.clone()); + let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| report_as_shown(r.clone())); let disabled: Vec = safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect(); Ok( json!({"plugins": registry::list().iter().map(|p| describe_installed(p)).collect::>(), "folder": folder, "safeMode": safe_mode::active(), "disabled": disabled}), @@ -1055,7 +1086,7 @@ pub fn specs() -> Vec { "List Plug-ins", [], None, - "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's or importer's format id, plugin., for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), 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; settings: it may keep settings of its own; cache: it may keep a cache of its own; commands: a filter may run the commands its commandAllowlist names, see docs/plugins.md), commandAllowlist?: [command id] (the commands a filter with the commands permission may run while it runs, with vc_call), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.., which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's or importer's format id, plugin., for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), 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; settings: it may keep settings of its own; cache: it may keep a cache of its own; commands: a filter may run the commands its commandAllowlist names, see docs/plugins.md), commandAllowlist?: [command id] (the commands a filter with the commands permission may run while it runs, with vc_call), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.., which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null (a folder or file outside the automation read roots is named by its file name, also in an error), safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", always, list ), @@ -1118,7 +1149,7 @@ pub fn specs() -> Vec { "Reload Plug-ins", ["Object", "Plug-ins"], None, - "{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module, and one disabled with plugin.setEnabled, is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted this very module, its bytes included; an error in Safe Mode) → {folder, loaded: [id], failed: [{file, error}]}", + "{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module, and one disabled with plugin.setEnabled, is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted this very module, its bytes included; an error in Safe Mode; without path, a folder or file outside the automation read roots is named by its file name in the reply and in an error) → {folder, loaded: [id], failed: [{file, error}]}", always, reload ), diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 8c86f0dfe3..772e1cdc0c 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -253,7 +253,8 @@ fn list_installed(s: &mut Session, _: &Value) -> Result { return Ok(json!({"folder": null, "scripts": [], "truncated": false, "unreadable": false})); }; let folder = root.to_string_lossy(); - crate::file_access::check_read(&folder).map_err(EngineError::Other)?; + // The Scripts folder is the app's: a refusal names it as the request may see it. + crate::file_access::check_read(&folder).map_err(|e| super::fileio::naming_in_error(EngineError::Other(e), &folder))?; let listing = script::folder::list(root); let scripts: Vec = listing .scripts() diff --git a/crates/engine/src/tests/links.rs b/crates/engine/src/tests/links.rs index f0a0674f08..a3c7a72aee 100644 --- a/crates/engine/src/tests/links.rs +++ b/crates/engine/src/tests/links.rs @@ -728,6 +728,56 @@ fn relink_each_with_copy_copies_nothing_when_a_file_cant_be_read() { assert_eq!(link_path(&s, a), one); } +/// With copy, for a client whose read roots don't hold the document's folder: the reply names the +/// copy by its file name, and a copy refused there keeps the write root and names the folder and +/// the file by their names. +#[test] +fn relink_with_copy_names_the_documents_folder_by_its_name_outside_the_read_roots() { + use crate::file_access::{AutomationRoots, confine}; + + let dir = Folder::new("relink-copy-roots"); + let one = dir.file("doc/one.png"); + write(&one, &png(60, 30, RED)); + let mut s = session(); + let a = place(&mut s, &one); + save(&mut s, &dir.file("doc/poster.vectorcraft")); + let x = dir.file("new/x.png"); + write(&x, &png(60, 30, BLUE)); + let (new, doc) = (Path::new(&x).parent().unwrap().to_path_buf(), Path::new(&one).parent().unwrap().to_path_buf()); + let hidden = doc.to_string_lossy().into_owned(); + let p = json!({"ids": [a.0], "path": x, "copy": true}); + + let writes_there = AutomationRoots::new(&[new.as_path()], &[doc.as_path()]).unwrap(); + let r = confine(writes_there.as_ref(), || s.execute("links.relink", &p)).unwrap(); + assert_eq!(r["copied"], json!([{"from": x, "to": "x.png"}]), "{r}"); + assert!(!r.to_string().contains(&hidden), "{r}"); + std::fs::remove_file(dir.file("doc/x.png")).unwrap(); + + let elsewhere = AutomationRoots::new(&[new.as_path()], &[new.as_path()]).unwrap(); + let root = crate::cmd::findfiles::plain(std::fs::canonicalize(&new).unwrap()).display().to_string(); + let e = confine(elsewhere.as_ref(), || s.execute("links.relink", &p)).unwrap_err().to_string(); + assert_eq!(e, format!("doc: automation path rejected: outside the write root {root}: x.png")); + assert!(!Path::new(&dir.file("doc/x.png")).exists()); +} + +/// With copy, for a document whose path has no folder: outside the read roots the error does not +/// name the path, and without roots it names it whole. +#[test] +fn relink_with_copy_names_a_document_path_without_a_folder_as_the_request_may_see_it() { + use crate::file_access::{AutomationRoots, confine}; + + let dir = Folder::new("relink-copy-no-folder"); + let mut s = session(); + s.doc_mut().unwrap().path = Some("/".into()); + let p = json!({"path": dir.file("x.png"), "copy": true}); + let roots = AutomationRoots::new(&[dir.0.as_path()], &[dir.0.as_path()]).unwrap(); + let e = confine(roots.as_ref(), || s.execute("links.relink", &p)).unwrap_err().to_string(); + assert_eq!(e, "invalid parameters for `links.relink`: a file or folder outside the read roots has no folder"); + let whole = s.execute("links.relink", &p).unwrap_err().to_string(); + let doc = crate::cmd::fileio::absolute_path("/"); + assert_eq!(whole, format!("invalid parameters for `links.relink`: {doc} has no folder")); +} + /// A scope or paths that doesn't fit is refused, and nothing is relinked. #[test] fn relink_refuses_scopes_and_paths_that_dont_fit() { diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index a1e615a08d..52820be60f 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1433,6 +1433,37 @@ fn plugins_folder_set_again_after_clearing_installs_its_plugins() { run(&mut s, "plugin.remove", json!({"id": id})); } +/// The Additional Plug-ins Folder, seen by a client whose read roots don't hold it: `plugin.list` +/// names the folder and the files that failed by their names, and `plugin.reload` without a path is +/// refused naming the folder by its name and keeping the root. A folder the client gives stays whole. +#[test] +fn a_plugins_folder_outside_the_read_roots_is_named_by_its_name() { + use crate::file_access::{AutomationRoots, confine}; + + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); + let dir = crate::tests_links::Folder::new("plugin-folder-hidden"); + let work = crate::tests_links::Folder::new("plugin-folder-work"); + std::fs::write(dir.0.join("broken.wasm"), b"\0asm?").unwrap(); + let mut s = session(); + let shown = dir.0.display().to_string(); + let name = dir.0.file_name().unwrap().to_string_lossy().into_owned(); + run(&mut s, "prefs.set", json!({"key": "pluginsFolder", "value": shown})); + let roots = AutomationRoots::new(&[work.0.as_path()], &[]).unwrap(); + let root = crate::cmd::findfiles::plain(std::fs::canonicalize(&work.0).unwrap()).display().to_string(); + + let list = confine(roots.as_ref(), || run(&mut s, "plugin.list", json!({}))); + assert_eq!(list["folder"]["folder"], json!(name), "{list}"); + assert_eq!(list["folder"]["failed"][0]["file"], json!("broken.wasm"), "{list}"); + assert!(!list.to_string().contains(&shown), "{list}"); + let e = confine(roots.as_ref(), || s.execute("plugin.reload", &json!({}))).unwrap_err().to_string(); + assert_eq!(e, format!("automation path rejected: outside the read root {root}: {name}")); + let e = confine(roots.as_ref(), || s.execute("plugin.reload", &json!({"path": shown}))).unwrap_err().to_string(); + assert_eq!(e, format!("automation path rejected: outside the read root {root}: {shown}")); + // Unconfined, the report names the folder whole. + assert_eq!(run(&mut s, "plugin.list", json!({}))["folder"]["folder"], json!(shown)); + run(&mut s, "prefs.set", json!({"key": "pluginsFolder", "value": ""})); +} + /// #2541: a plug-in installed in a session with a plug-in store is installed again in the next /// session that attaches the store, and Remove Plug-in takes it out of the store. #[test] diff --git a/crates/engine/src/tests/save.rs b/crates/engine/src/tests/save.rs index 9932055652..8d557bb4ff 100644 --- a/crates/engine/src/tests/save.rs +++ b/crates/engine/src/tests/save.rs @@ -84,6 +84,55 @@ fn a_confined_save_leaves_out_paths_outside_the_read_root() { let _ = std::fs::remove_dir_all(d); } +/// A save without a path, and Revert, refused because the document's own file is outside a confined +/// client's roots: the error keeps the root and the reason and names the file by its file name, also +/// for the files a save writes beside it. A path the client gives stays whole in the error. +#[test] +fn pathless_save_rejection_hides_document_folder() { + use crate::file_access::{AutomationRoots, confine}; + + let d = dir("roots-error"); + let (inside, outside) = (d.join("in"), d.join("kept-out")); + for f in [&inside, &outside] { + std::fs::create_dir_all(f).unwrap(); + } + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + rect(&mut s); + let own = path(&outside, "doc.vectorcraft"); + s.execute("document.save", &json!({"path": own})).unwrap(); + rect(&mut s); + let roots = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap(); + let root = crate::cmd::findfiles::plain(std::fs::canonicalize(&inside).unwrap()).display().to_string(); + let hidden = outside.to_string_lossy().into_owned(); + let mut refused = |id: &str, p: Value| confine(roots.as_ref(), || s.execute(id, &p)).unwrap_err().to_string(); + + let e = refused("document.save", json!({})); + assert_eq!(e, format!("automation path rejected: outside the write root {root}: doc.vectorcraft")); + let e = refused("document.save", json!({"separateArtboards": true})); + assert!(e.starts_with(&format!("automation path rejected: outside the write root {root}: ")) && !e.contains(&hidden), "{e}"); + let e = refused("file.revert", json!({})); + assert_eq!(e, format!("automation path rejected: outside the read root {root}: doc.vectorcraft")); + // Given by the client: whole. + let e = refused("document.save", json!({"path": own})); + assert_eq!(e, format!("automation path rejected: outside the write root {root}: {own}")); + // Without read roots, a write refusal still names the file by its file name. + let write_only = AutomationRoots::new(&[], &[inside.as_path()]).unwrap(); + let e = confine(write_only.as_ref(), || s.execute("document.save", &json!({}))).unwrap_err().to_string(); + assert_eq!(e, format!("automation path rejected: outside the write root {root}: doc.vectorcraft")); + assert!(s.doc().unwrap().is_dirty(), "nothing was saved"); + // Read with losses from its own file: the refusal to write over it names the file by its file name. + let st = s.doc_mut().unwrap(); + st.imported_from = Some(own.clone()); + st.import_losses = vec!["hidden text".into()]; + let e = confine(roots.as_ref(), || s.execute("document.save", &json!({}))).unwrap_err().to_string(); + assert!(e.starts_with("invalid parameters for `document.save`: doc.vectorcraft is the file this document was read from"), "{e}"); + assert!(!e.contains(&hidden), "{e}"); + let e = confine(roots.as_ref(), || s.execute("document.save", &json!({"path": own}))).unwrap_err().to_string(); + assert!(e.contains(&format!("{own} is the file this document was read from")), "{e}"); + let _ = std::fs::remove_dir_all(d); +} + #[test] fn an_svg_path_writes_svg_and_save_writes_it_back() { let d = dir("svg"); diff --git a/crates/engine/src/tests/script_folder.rs b/crates/engine/src/tests/script_folder.rs index b176b4f143..312db3e0fc 100644 --- a/crates/engine/src/tests/script_folder.rs +++ b/crates/engine/src/tests/script_folder.rs @@ -311,7 +311,9 @@ fn the_automation_read_root_keeps_an_agent_out_of_the_scripts_folder() { s.attach_scripts_dir(root.clone()); let roots = crate::file_access::AutomationRoots::new(&[&work], &[]).unwrap().unwrap(); let e = crate::file_access::confine(Some(&roots), || s.execute("script.listInstalled", &json!({}))).unwrap_err(); - assert!(e.to_string().contains("outside the read root"), "{e}"); + // The Scripts folder is the app's: the error keeps the root and names the folder by its name. + let shown_root = crate::cmd::findfiles::plain(std::fs::canonicalize(&work).unwrap()).display().to_string(); + assert_eq!(e.to_string(), format!("automation path rejected: outside the read root {shown_root}: Scripts")); let e = crate::file_access::confine(Some(&roots), || s.installed_script(&script.to_string_lossy())).unwrap_err(); assert!(e.to_string().contains("outside the read root"), "{e}"); // A read root that holds the folder lets both through. diff --git a/docs/mcp.md b/docs/mcp.md index fa22c66414..4931fed70d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -202,6 +202,12 @@ the suite) keep the agent to the folders you give it: `outside the write roots /work/project/out, /tmp/scratch: …`) or `automation filesystem access is not granted: write authority is absent: /work/project/out/x.png`. Links in an opened document that point outside the read roots show as missing. +- An error names a path the client gave as given. A path the client did not give (the document's own file + in a save without `path` and in `file.revert`, also inside `action.batch`, an export preset's folder, the + Scripts folder, the Additional Plug-ins Folder, the document's folder in `links.relink {copy}`) is named + whole only inside the read roots; outside them the error names it by its file name, or as + `a file or folder outside the read roots` when it has none, and keeps the roots and the reason, for example + `automation path rejected: outside the write root /work/project/out: poster.vectorcraft`. - The preferences that move folders the app reads or writes on its own (`fontsFolder`, `pluginsFolder`, `recoveryFolder`, `templatesFolder`) can only be set inside the roots. The app's own folders (preferences, Data Recovery, the User Defined library folders, VectorCraft's Fonts folder) are not diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d42a4d988b..ef1059c692 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -764,6 +764,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens (in the macOS menu bar, when any of its menus opens, and the new listing shows the next time the menu opens), links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. - Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`. - Saves, Export for Screens and Package under `--automation-read-root`: a save without a path leaves the document's own file out of its reply, and a save without a path known leaves out the suggested folder, when they are outside the read roots; Export for Screens into an export preset's folder outside them lists its files relative to that folder and names that folder by its name in an error, and the export settings and saved presets leave out a folder outside them when read or exported; Package's report and warnings, and Document Info's linked images, name a file outside them by its file name. A path the client gives stays in the reply. `crates/engine/src/tests/save.rs`, `crates/engine/src/cmd/fileio/tests_screens.rs`, `crates/engine/src/tests/package.rs`, `crates/ui-egui/src/tests/automation.rs`. + - Errors under `--automation-read-root`: a refused save without a path and `file.revert` (also as an `action.batch` step), `script.listInstalled`, `plugin.reload` without a path and `links.relink {copy}` name the document's own file, the Scripts folder, the Additional Plug-ins Folder or the document's folder by its file name when it is outside the read roots, and keep the roots and the reason; `plugin.list`'s folder report and `links.relink`'s copies name such a folder or file by its file name too. A path the client gives stays whole. `crates/engine/src/tests/save.rs` (`pathless_save_rejection_hides_document_folder`), `crates/engine/src/cmd/fileio/action_batch.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/src/tests/links.rs`, `crates/engine/src/tests/script_folder.rs`. - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). - Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`). - Linked-file and font-file searches under `--automation-read-root`: `links.findFiles` and `text.findFontFiles` replies, also later polls and stops of a search another client started, leave out the files found, a link's saved path and the folder searched outside the caller's read roots, and a failed search gives its reason without the path; `image.info` gives `link: null` for a linked file outside them; `text.addFontFiles` gives `folder: null` and the copies' file names when the Fonts folder is outside them. `crates/engine/src/tests/linkfiles.rs`, `crates/engine/src/tests/fontfiles.rs`, `crates/engine/tests/font_folder.rs`. From 072c0b79b99bb6e83b39101ae4ab1078ba8c7d4d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:06:11 -0800 Subject: [PATCH 23/92] Stroke: an arrowheads file whose data goes on past the size limit is not read A compressed arrowheads file is refused when the data after its document, the preview included, unpacks to more than 64 MB, so the whole stream is read and its checksum checked. Other files are read as before. --- crates/format/src/lib.rs | 4 ++-- crates/format/src/unpack.rs | 33 +++++++++++++++++++++------------ 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index f3efb3784b..52439ca3a6 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -504,8 +504,8 @@ pub fn load_file(bytes: &[u8]) -> Result { load_file_within(bytes, vectorcraft_doc::node::MAX_READ_NODES, vectorcraft_doc::node::MAX_BUFFERED_VALUES) } -/// [`load_file`] with a compressed file refused when its JSON, or the data after it that the -/// document uses (images, profiles), unpacks to more than `max` bytes (at most [`MAX_JSON`] and +/// [`load_file`] with a compressed file refused when its JSON, or the data after it (images, +/// profiles, the preview), unpacks to more than `max` bytes (at most [`MAX_JSON`] and /// [`MAX_BLOBS`]), or, when it lacks the marker after its JSON, when all of it does. pub fn load_file_capped(bytes: &[u8], max: u64) -> Result { let file = unpack::Unpacked::within_capped(bytes, max.min(MAX_JSON), max.min(MAX_UNPACKED), max.min(MAX_BLOBS))?; diff --git a/crates/format/src/unpack.rs b/crates/format/src/unpack.rs index fed726bfab..ec9623118c 100644 --- a/crates/format/src/unpack.rs +++ b/crates/format/src/unpack.rs @@ -32,6 +32,9 @@ pub(crate) struct Unpacked<'a> { max_blobs: u64, /// Most bytes unpacked to find them in a streamed file ([`MAX_WORK`] unless capped). max_work: u64, + /// Must a streamed file's data after the JSON end within `max_blobs` bytes + /// ([`Self::within_capped`])? + capped: bool, } enum Json<'a> { @@ -72,16 +75,18 @@ impl<'a> Unpacked<'a> { /// a document several times its size, and the cap is what keeps that off the address space /// (#3587). fn within(bytes: &'a [u8], max_json: u64) -> Result { - Self::within_capped(bytes, max_json, MAX_UNPACKED, MAX_BLOBS) + let (json, tail) = Self::open(bytes, max_json, MAX_UNPACKED)?; + Ok(Self { json, tail, max_blobs: MAX_BLOBS, max_work: max_json.saturating_add(MAX_BLOBS).min(MAX_WORK), capped: false }) } /// [`Self::within`], with a compressed file that lacks the marker after its JSON (unpacked /// whole) capped at `max_whole` bytes instead of [`MAX_UNPACKED`], the entries after the JSON /// at `max_blobs` bytes together instead of [`MAX_BLOBS`], and the bytes unpacked to find them - /// at `max_json` and `max_blobs` together instead of [`MAX_WORK`]. + /// at `max_json` and `max_blobs` together instead of [`MAX_WORK`]. A streamed file whose data + /// after the JSON unpacks to more than `max_blobs` bytes is refused when its entries are read. pub(crate) fn within_capped(bytes: &'a [u8], max_json: u64, max_whole: u64, max_blobs: u64) -> Result { let (json, tail) = Self::open(bytes, max_json, max_whole)?; - Ok(Self { json, tail, max_blobs, max_work: max_json.saturating_add(max_blobs).min(MAX_WORK) }) + Ok(Self { json, tail, max_blobs, max_work: max_json.saturating_add(max_blobs).min(MAX_WORK), capped: true }) } /// The JSON of `bytes` and where the data after it is, within `max_json` and `max_whole`. @@ -160,7 +165,7 @@ impl<'a> Unpacked<'a> { /// [`Self::entries`], unpacking at most `work` bytes of a streamed file to find them. fn entries_within(self, wanted: &[[u64; 2]], work: u64) -> Result, FormatError> { - let max_blobs = self.max_blobs; + let (max_blobs, capped) = (self.max_blobs, self.capped); let mut out: Vec = vec![None; wanted.len()]; // Data in memory is read through the crate's `Entries`: entries naming the same bytes // share them, and the bytes copied stay within the data. @@ -214,9 +219,10 @@ impl<'a> Unpacked<'a> { } done.insert(w, slot.clone()); } - // To the end, so the stream's checksum is verified as before. - s.left = max_blobs; - s.skip(max_blobs)?; + // To the end, so the stream's checksum is verified as before. A capped file's data + // must end within its cap from its start: one byte more is over the budget. + s.left = if capped { max_blobs.checked_sub(s.pos).ok_or(FormatError::TooBig)? } else { max_blobs }; + s.skip(if capped { s.left.saturating_add(1) } else { max_blobs })?; } } Ok(out) @@ -339,11 +345,14 @@ mod tests { assert_eq!(read(&[[1 << 40, 1]], 1 << 20).unwrap(), vec![None]); assert_eq!(Unpacked::new(&file).unwrap().entries(&[[0, 4096]]).unwrap(), vec![Some(Arc::new(tail.clone()))]); // A capped file finds its entries within its own caps: a small entry far into the data, - // or entries larger than the cap together, are refused. - let capped = || Unpacked::within_capped(&file, 64, MAX_UNPACKED, 16).unwrap(); - assert_eq!(capped().entries(&[[0, 16]]).unwrap(), vec![Some(Arc::new(tail[..16].to_vec()))]); - assert!(matches!(capped().entries(&[[4000, 1]]), Err(FormatError::TooBig))); - assert!(matches!(capped().entries(&[[0, 17]]), Err(FormatError::TooBig))); + // entries larger than the cap together, or data that goes on past the cap after the + // entries, are refused. + let capped = |cap: u64| Unpacked::within_capped(&file, 64, MAX_UNPACKED, cap).unwrap(); + assert_eq!(capped(4096).entries(&[[0, 16]]).unwrap(), vec![Some(Arc::new(tail[..16].to_vec()))]); + assert!(matches!(capped(4095).entries(&[[0, 16]]), Err(FormatError::TooBig))); + assert!(matches!(capped(4095).entries(&[[4095, 1]]), Err(FormatError::TooBig))); + assert!(matches!(capped(16).entries(&[[4000, 1]]), Err(FormatError::TooBig))); + assert!(matches!(capped(16).entries(&[[0, 17]]), Err(FormatError::TooBig))); assert_eq!(Unpacked::new(&file).unwrap().entries(&[[4000, 1]]).unwrap(), vec![Some(Arc::new(vec![tail[4000]]))]); } From ccc962300c7482b81c9b9468be91158aa714f7de Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:24:48 -0800 Subject: [PATCH 24/92] =?UTF-8?q?Effects:=20Object=20=E2=80=BA=20Expand,?= =?UTF-8?q?=20Outline=20Stroke=20and=203D=20keep=20the=20raster=20effects?= =?UTF-8?q?=20a=20Transform=20effect=20moves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A raster effect listed before a Transform effect follows that Transform effect on the canvas. Several commands apply and remove the geometry effects, and they dropped the Transform effect while the raster effect kept its old values: Object › Expand of a gradient or pattern fill, Outline Stroke (also Expand's Stroke option and Flatten Transparency's Convert All Strokes to Outlines) and the 3D effects. For a square with a Drop Shadow and a 200% Transform effect, the canvas drew a 20 pt offset and these commands gave a 10 pt one. - These commands now keep the raster effects at the values the canvas uses (without_geometry). - A fill's or stroke's own Transform effect with Transform Patterns or Scale Strokes & Effects on now transforms the raster effects listed before it, as one without these options does. Before, Scale Strokes & Effects only scaled them, and Transform Patterns left them as they were. - In a fill's or stroke's own effects, a Transform effect that makes copies or moves at random keeps the raster effects before it from following the Transform effects after it, as in an object's effects. - The params text and docs/target-app-parity.md say that pixel filters keep their angles and light directions. --- crates/effects/src/catalog.rs | 2 +- crates/effects/src/lib.rs | 2 +- crates/effects/src/raster.rs | 71 +++++++++----- crates/effects/src/reshape.rs | 68 ++++++++++--- crates/effects/src/revolve.rs | 19 ++-- crates/effects/src/solids.rs | 22 ++--- crates/effects/src/tests/rasterfollow.rs | 116 +++++++++++++++++++++++ crates/engine/src/cmd/expand.rs | 13 ++- crates/engine/src/cmd/pathops.rs | 8 +- crates/engine/src/tests/objexpand.rs | 21 ++++ crates/engine/src/tests/outlinestroke.rs | 24 +++++ docs/target-app-parity.md | 2 +- 12 files changed, 298 insertions(+), 70 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 56d9cf601e..8edea9d535 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -265,7 +265,7 @@ pub fn effect_catalog() -> Vec { "distort.transform", "Transform…", DT, - "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once; on, without copies or random, the raster effects listed before this effect (Rasterize and SVG filters excepted) transform with the art whatever strokes says: their sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", + "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once; on, without copies or random, the raster effects listed before this effect (Rasterize and SVG filters excepted) transform with the art whatever strokes says: their sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales, while the angles and light directions of pixel filters stay as they are), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}), ), g( diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 92ee086147..b211a6f94a 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -78,7 +78,7 @@ pub use lighting_presets::{LIGHTING_PRESETS, LightingPreset, lighting_preset_par pub use live::{expand_live, expand_live_deep, expanded_live_group, outline_object, text_outliner}; pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; -pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects}; +pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects, without_geometry}; pub use reshape::{ SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape, diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index d79aac09cc..07140e2bc9 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::color::{BlendMode, Color}; use vectorcraft_doc::rastersettings::MAX_ADD_AROUND; use vectorcraft_doc::{Effect, RasterColorModel, RasterEffectsSettings}; -use vectorcraft_geom::{Affine, Point, Rect}; +use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use crate::merged_params; use crate::pixel::{self, PixelFx, PixelSpace}; @@ -437,7 +437,7 @@ pub fn raster_effects(effects: &[Effect]) -> Vec { /// random follows stays as it is, as does one after the last Transform effect. Borrowed when /// nothing changes. pub(crate) fn followed(effects: &[Effect]) -> Cow<'_, [Effect]> { - if !effects.iter().any(|e| e.visible && e.id == TRANSFORM) { + if !effects.iter().any(|e| e.visible && e.id == "distort.transform") { return Cow::Borrowed(effects); } let mut out: Option> = None; @@ -445,16 +445,8 @@ pub(crate) fn followed(effects: &[Effect]) -> Cow<'_, [Effect]> { // `None` once one of them makes copies or moves at random. let mut after = Some(Affine::IDENTITY); for (i, e) in effects.iter().enumerate().rev() { - if e.id == TRANSFORM { - if !e.visible { - continue; - } - let p = merged_params(&e.id, &e.params); - // With Transform Objects off the art stays where it is. - if flag(&p, "objects", true) { - let (map, copies) = crate::distort::transform_map(Rect::ZERO, &p, 0); - after = after.filter(|_| copies == 0 && !flag(&p, "random", false)).map(|a| a * linear(map)); - } + if e.id == "distort.transform" { + after = after.and_then(|a| transform_step(e).map(|s| a * s)); continue; } let Some(a) = after.filter(|a| *a != Affine::IDENTITY && follows(&e.id)) else { continue }; @@ -467,13 +459,54 @@ pub(crate) fn followed(effects: &[Effect]) -> Cow<'_, [Effect]> { } /// `effects` without their geometry effects, each raster effect following the Transform effects -/// it loses ([`followed`]): what an object keeps once its geometry effects are applied to its art. -pub(crate) fn without_geometry(effects: &[Effect]) -> Vec { +/// it loses ([`followed`]): what an object, or a fill or stroke, keeps once its geometry effects +/// are applied to its art. +pub fn without_geometry(effects: &[Effect]) -> Vec { followed(effects).iter().filter(|e| !crate::is_geometry(&e.id)).cloned().collect() } -/// The Transform effect's id. -const TRANSFORM: &str = "distort.transform"; +/// Make the raster effects of `effects`, all listed before Transform effect `t`, follow `t` as +/// [`followed`] makes them follow it, for art that applies its effects one at a time. `false` +/// when `t` leaves them as they are. +pub(crate) fn follow_one(effects: &mut [Effect], t: &Effect) -> bool { + let Some(a) = transform_step(t).filter(|a| *a != Affine::IDENTITY) else { return false }; + let mut changed = false; + for e in effects.iter_mut().filter(|e| follows(&e.id)) { + follow_transform(e, a); + changed = true; + } + changed +} + +/// How many of the effects of `effects` that [`without_geometry`] keeps no longer follow the +/// Transform effects listed after `effects` ([`followed`]): those before the last visible Transform +/// effect among them that makes copies or moves at random. +pub(crate) fn held_back(effects: &[Effect]) -> usize { + let Some(last) = effects.iter().rposition(|e| transform_step(e).is_none()) else { return 0 }; + effects.get(..last).unwrap_or_default().iter().filter(|e| !crate::is_geometry(&e.id)).count() +} + +/// Does `e` stop the raster effects listed before it from following the Transform effects after +/// it ([`followed`]): is it a visible Transform effect that makes copies or moves at random? +pub(crate) fn holds_back(e: &Effect) -> bool { + transform_step(e).is_none() +} + +/// What `e` does to the raster effects listed before it ([`followed`]): the linear part of its map +/// when it is a visible Transform effect that moves the art once, the identity when it is no +/// Transform effect, is hidden or has Transform Objects off (the art stays where it is), and `None` +/// when it makes copies or moves at random. +fn transform_step(e: &Effect) -> Option { + if e.id != "distort.transform" || !e.visible { + return Some(Affine::IDENTITY); + } + let p = merged_params(&e.id, &e.params); + if !flag(&p, "objects", true) { + return Some(Affine::IDENTITY); + } + let (map, copies) = crate::distort::transform_map(Rect::ZERO, &p, 0); + (copies == 0 && !flag(&p, "random", false)).then(|| map.with_translation(Vec2::ZERO)) +} /// Does raster effect `id` follow the Transform effects listed after it ([`followed`])? An SVG /// filter does not, and neither does Rasterize, whose image keeps its own pixel grid and room @@ -482,12 +515,6 @@ fn follows(id: &str) -> bool { crate::is_raster(id) && id != crate::SVG_FILTER && id != RASTERIZE } -/// `a` without its translation. -fn linear(a: Affine) -> Affine { - let [a0, a1, a2, a3, _, _] = a.as_coeffs(); - Affine::new([a0, a1, a2, a3, 0.0, 0.0]) -} - /// Make raster effect `e` follow `a`, the linear part of a Transform effect listed after it, as /// that effect transforms what `e` paints: its distances and raster sizes scale by `a`'s mean /// scale ([`crate::scale_effect`]), and a Drop Shadow's offset turns, reflects and scales with diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 36e34a90b4..b25c016cfd 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -1263,9 +1263,12 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt let params = merged_params(&fx[at].id, &fx[at].params); // Where each effect the piece keeps sits among the item's effects. let kept: Vec = (0..at).filter(|&k| !is_geometry(&fx[k].id)).chain(at + 1..fx.len()).collect(); + // The effects before the Transform effect that it keeps, each raster effect following the + // geometry effects before it ([`crate::raster::without_geometry`]). + let kept_before = crate::without_geometry(before); if let Some(plugin) = painted_plugin(&fx[at]) { let mut it = item.clone(); - let rest: Vec = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect(); + let rest: Vec = kept_before.iter().chain(&fx[at + 1..]).cloned().collect(); *it.effects_mut() = rest; let mut one = Appearance { items: vec![it.clone()], ..Default::default() }; if carry.moves(&one) { @@ -1287,7 +1290,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt group.push(Arc::new(p)); } let mut p = Node::group(leaf.id, group); - transform_piece(&mut p, (ctx.seed, i, &kept), limit, used, doc); + transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(before), limit, used, doc); pieces.push(Arc::new(p)); continue; } @@ -1295,16 +1298,20 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt let options = crate::distort::TransformOptions::of(¶ms); let map = crate::distort::transform_affine(tb, ¶ms, ctx.seed); let mut it = item.clone(); - let rest: Vec = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect(); + let rest: Vec = kept_before.iter().chain(&fx[at + 1..]).cloned().collect(); *it.effects_mut() = rest; let mut p = piece(vec![it]); if carry.moves(&p.appearance) { carry.apply(&mut p.appearance); } set_geometry(&mut p, g.clone(), rule); - let (made, _) = transform_leaf(&mut p, &g, map, copies, options, room); + let (made, moved) = transform_leaf(&mut p, &g, map, copies, options, room); *used = used.plus(made); - transform_piece(&mut p, (ctx.seed, i, &kept), limit, used, doc); + let from = crate::raster::held_back(before); + if moved { + follow_piece(&mut p, kept_before.get(from..).unwrap_or_default(), from, &fx[at]); + } + transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(&fx[..=at]), limit, used, doc); pieces.push(Arc::new(p)); } if !plain.is_empty() { @@ -1324,24 +1331,32 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt /// the effect, whose distances its Scale Strokes & Effects scaled. A Transform effect with /// Transform Patterns or Scale Strokes & Effects on transforms each path as [`transform_leaf`] does. /// A live-effect plug-in that paints what it returns ([`painted_plugin`]) makes each path one path -/// for each piece it returns ([`paint_piece`]). The geometry effects then leave the fills and -/// strokes; their other effects stay. The paths keep to `max` together with the rest of the art -/// (`used`), and the swatches and patterns painted pieces name must be in `doc`. `(seed, source, -/// kept)`: the object's seed, the index in the object's appearance of the fill or stroke the -/// piece was made from, and the index among that item's effects of each effect the piece kept, -/// which a deferred live-effect plug-in is listed by. -fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), max: Budget, used: &mut Budget, doc: Option<&Document>) { +/// for each piece it returns ([`paint_piece`]). The raster effects before a Transform effect +/// follow it ([`follow_piece`]), except the first `held` effects, which a Transform effect before +/// the piece's effects that makes copies or moves at random holds back. The geometry effects then +/// leave the fills and strokes; their other effects stay. The paths keep to `max` together with +/// the rest of the art (`used`), and the swatches and patterns painted pieces name must be in +/// `doc`. `(seed, source, kept)`: the object's seed, the index in the object's appearance of the +/// fill or stroke the piece was made from, and the index among that item's effects of each effect +/// the piece kept, which a deferred live-effect plug-in is listed by. +fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), held: usize, max: Budget, used: &mut Budget, doc: Option<&Document>) { let mut count = 0; for_each_leaf(p, &mut |l| { if let Some(item) = l.appearance.items.first() { count = count.max(item.effects().len()); } }); + let mut held = held; for j in 0..count { let Some(bounds) = p.geometric_bounds() else { break }; + let from = held.min(j); + let mut holds = false; for_each_leaf(p, &mut |leaf| { let Some(item) = leaf.appearance.items.first().cloned() else { return }; let Some(e) = item.effects().get(j).filter(|e| e.visible && is_geometry(&e.id)).cloned() else { return }; + holds |= crate::raster::holds_back(&e); + // The effects before this one as they were before it moved the path. + let was = item.effects().get(from..j).unwrap_or_default(); let Some((path, rule)) = leaf_geometry(leaf) else { return }; // What the other paths leave of the budget. let before = Budget::of(&path); @@ -1358,8 +1373,11 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), m let (_, copies) = crate::distort::transform_map(bounds, ¶ms, seed); let options = crate::distort::TransformOptions::of(¶ms); let map = crate::distort::transform_affine(bounds, ¶ms, seed); - let (made, _) = transform_leaf(leaf, &path, map, copies, options, room); + let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room); *used = used.minus(before).plus(made); + if moved { + follow_piece(leaf, was, from, &e); + } return; } let appearance = leaf.appearance.clone(); @@ -1376,7 +1394,11 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), m carry.apply(&mut leaf.appearance); } set_geometry(leaf, out, rule); + follow_piece(leaf, was, from, &e); }); + if holds { + held = j; + } } for_each_leaf(p, &mut |leaf| { for item in &mut leaf.appearance.items { @@ -1385,6 +1407,26 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), m }); } +/// Make the raster effects of `leaf`'s first fill or stroke from index `from` on follow Transform +/// effect `t`, listed after them, once `t` has moved the leaf ([`crate::raster::follow_one`]). +/// `was` holds those effects as they were before `t` was applied: each one that follows takes the +/// value [`crate::raster::followed`] gives it, in place of what Scale Strokes & Effects made of it, +/// and the others keep theirs. +fn follow_piece(leaf: &mut Node, was: &[Effect], from: usize, t: &Effect) { + let mut follow = was.to_vec(); + if !crate::raster::follow_one(&mut follow, t) { + return; + } + let Some(item) = leaf.appearance.items.first_mut() else { return }; + for (k, (new, old)) in follow.into_iter().zip(was).enumerate() { + if new != *old + && let Some(e) = item.effects_mut().get_mut(from + k) + { + *e = new; + } + } +} + /// The bounds of each group of type's outlines under `n` ([`outline_text`]), `n` included, in /// order. A group inside one of them is part of it. fn type_outline_bounds(n: &Node, out: &mut Vec>) { diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index e7fd5e5f22..aed6240531 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -8,7 +8,9 @@ use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, L use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2}; use vectorcraft_three_d::{Edge, Revolve}; -use crate::solids::{Profile, Scope, is_container, members, object_profile, profile, reshape_members, resolve_for, solid_paints, union_bounds}; +use crate::solids::{ + Profile, Scope, is_container, members, object_profile, past_effect, profile, reshape_members, resolve_for, solid_paints, union_bounds, +}; use crate::util::{flag, num, text}; use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params}; @@ -558,17 +560,11 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { } children.push(Arc::new(surface)); // Later geometry acts on the projected art as a whole through container evaluation. - remaining = n - .appearance - .effects - .iter() - .enumerate() - .filter(|(i, e)| !is_revolve(&e.id) && (!is_geometry(&e.id) || *i > index)) - .map(|(_, e)| e.clone()) - .collect(); + remaining = past_effect(&n.appearance.effects, index); + remaining.retain(|e| !is_revolve(&e.id)); } else { let path = geometry(&path, &n.appearance.effects, &ctx); - remaining.retain(|e| !is_geometry(&e.id)); + remaining = crate::without_geometry(&remaining); // Every item's shadow goes behind every item's art. let mut shadows = vec![]; for item in n.appearance.items.iter().filter(|i| crate::paints(i)) { @@ -830,8 +826,7 @@ fn group_art(n: &Node, expand: bool) -> Option { // One shadow for every member's surface, behind them all. let mut children = children; children.splice(0..0, group_shadow(n.id, fx, &shapes, b, options).ok()?); - let remaining = - n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); + let remaining = past_effect(&n.appearance.effects, index); let mut art = n.clone(); art.kind = NodeKind::Group { children, clip: false }; art.appearance = Appearance { effects: remaining, ..Default::default() }.into(); diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 8d9ff9eea8..4d04a4009a 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -913,17 +913,10 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option { let (shadow, surfaces) = art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }])?; children.extend(shadow.into_iter().chain(surfaces)); // Later geometry effects act on the projected art as a whole. - remaining = n - .appearance - .effects - .iter() - .enumerate() - .filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)) - .map(|(_, e)| e.clone()) - .collect(); + remaining = past_effect(&n.appearance.effects, index); } else { let path = geometry(&path, &n.appearance.effects, &ctx); - remaining = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); + remaining = crate::without_geometry(&n.appearance.effects); // Every item's shadow goes behind every item's art. let mut shadows = vec![]; for item in &paints { @@ -959,14 +952,21 @@ fn group_solid_art(n: &Node) -> Option { reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes); let (shadow, surfaces) = art_for(n, fx, &shapes)?; let children = shadow.into_iter().chain(surfaces).collect(); - let remaining = - n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); + let remaining = past_effect(&n.appearance.effects, index); let mut art = n.clone(); art.kind = NodeKind::Group { children, clip: false }; art.appearance = Appearance { effects: remaining, ..Default::default() }.into(); Some(art) } +/// `effects` without the 3D effect at `index` and the geometry effects before it, which shape the +/// art it projects. Each raster effect before it follows the Transform effects among those +/// ([`crate::without_geometry`]); the effects after it stay as they are. +pub(crate) fn past_effect(effects: &[Effect], index: usize) -> Vec { + let (before, after) = effects.split_at(index.min(effects.len())); + crate::without_geometry(before).into_iter().chain(after.iter().skip(1).cloned()).collect() +} + /// One solid of a 3D object as a mesh, with the color it is painted. #[derive(Clone, Debug, PartialEq)] pub struct SolidMesh { diff --git a/crates/effects/src/tests/rasterfollow.rs b/crates/effects/src/tests/rasterfollow.rs index 8212c11487..5d578d15a8 100644 --- a/crates/effects/src/tests/rasterfollow.rs +++ b/crates/effects/src/tests/rasterfollow.rs @@ -125,6 +125,122 @@ fn rasterize_before_a_transform_keeps_its_options() { assert_eq!(crate::raster::without_geometry(&effects), vec![rasterize]); } +/// An SVG filter keeps its options: it paints in its own user space. +#[test] +fn an_svg_filter_before_a_transform_keeps_its_options() { + let filter = fx(crate::SVG_FILTER, json!({"origin": [10, 10]})); + let effects = [filter.clone(), transform(json!({"scaleH": 200, "scaleV": 200, "rotate": 90}))]; + assert_eq!(crate::raster::followed(&effects)[0], filter); + assert_eq!(crate::raster::without_geometry(&effects), vec![filter]); +} + +/// The offset of every Drop Shadow drawn on `n` and the nodes under it, from the object's effects +/// and from each fill's and stroke's. +fn shadows_in(n: &Node) -> Vec<(f64, f64)> { + let mut out = vec![]; + n.walk(&mut |m| { + let lists = std::iter::once(&m.appearance.effects).chain(m.appearance.items.iter().map(|i| i.effects())); + for list in lists { + for r in raster_effects(list) { + if let RasterFx::DropShadow { dx, dy, .. } = r { + out.push((dx, dy)); + } + } + } + }); + out +} + +fn near(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(p, q)| (p.0 - q.0).abs() < 1e-9 && (p.1 - q.1).abs() < 1e-9) +} + +/// A square whose fill carries `effects` of its own. +fn fill_square(id: u64, effects: Vec) -> Node { + let mut n = square(id, vec![]); + if let Some(item) = n.appearance.items.first_mut() { + *item.effects_mut() = effects; + } + n +} + +/// A fill's own Transform effect transforms the fill's raster effects before it: drawn as they are +/// (no options), drawn through the art the Transform Patterns or Scale Strokes & Effects option +/// makes, expanded and baked. Scale Strokes & Effects does not scale a shadow that follows the +/// Transform effect a second time. +#[test] +fn a_fills_shadow_follows_the_fills_own_transform() { + let cases = [ + (json!({"rotate": 90}), (0.0, -10.0)), + (json!({"rotate": 90, "strokes": true}), (0.0, -10.0)), + (json!({"scaleH": 200, "scaleV": 200, "strokes": true}), (20.0, 0.0)), + (json!({"scaleH": 200, "scaleV": 200, "patterns": true}), (20.0, 0.0)), + (json!({"reflectX": true, "patterns": true}), (-10.0, 0.0)), + ]; + for (params, want) in cases { + let n = fill_square(11, vec![shadow(10.0, 0.0, 0.0), transform(params.clone())]); + // Drawn and expanded through its art when an option makes some. + if crate::has_transform_art(&n) { + let art = crate::reshape(None, &n, None).unwrap(); + assert!(near(&shadows_in(&art), &[want]), "{params}: drawn {:?}", shadows_in(&art)); + let expanded = expand_outlined(None, &n, None).unwrap(); + assert!(near(&shadows_in(&expanded), &[want]), "{params}: expanded {:?}", shadows_in(&expanded)); + } else { + assert!(near(&shadows_in(&n), &[want]), "{params}: drawn {:?}", shadows_in(&n)); + } + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, n).unwrap(); + let baked = bake_document(&d).unwrap(); + let b = baked.node(NodeId(11)).unwrap(); + assert!(near(&shadows_in(b), &[want]), "{params}: baked {:?}", shadows_in(b)); + } +} + +/// A Transform effect after a fill's own Transform effect with Scale Strokes & Effects on turns the +/// shadow between them, which the first one scaled; a Transform effect that moves at random holds +/// the shadow before it back from the Transform effects after it. +#[test] +fn a_fills_later_transform_turns_the_shadow_its_options_scaled() { + let n = fill_square( + 12, + vec![transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})), shadow(10.0, 0.0, 0.0), transform(json!({"rotate": 90}))], + ); + let art = crate::reshape(None, &n, None).unwrap(); + assert!(near(&shadows_in(&art), &[(0.0, -20.0)]), "{:?}", shadows_in(&art)); + let held = fill_square( + 13, + vec![ + shadow(10.0, 0.0, 0.0), + transform(json!({"scaleH": 200, "scaleV": 200, "random": true, "strokes": true})), + transform(json!({"rotate": 90})), + ], + ); + let art = crate::reshape(None, &held, None).unwrap(); + let drawn = shadows_in(&art); + assert!(drawn.len() == 1 && drawn[0].0 > 0.0 && drawn[0].1.abs() < 1e-9, "{drawn:?}"); +} + +/// The 3D effects keep the shadow before a Transform effect that shapes the art they project, the +/// object's and its fill's 3D effect alike. +#[test] +fn a_shadow_before_a_transform_follows_it_into_3d_art() { + let before = vec![shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200}))]; + for three_d in [crate::EXTRUDE, crate::REVOLVE] { + let art = |n: &Node| if three_d == crate::EXTRUDE { crate::solid_art(n) } else { crate::revolve_art(n) }; + let mut on_object = before.clone(); + on_object.push(fx(three_d, json!({}))); + let projected = art(&square(14, on_object)).unwrap(); + assert!(near(&shadows_in(&projected), &[(20.0, 0.0)]), "{three_d} on the object: {:?}", shadows_in(&projected)); + let mut on_fill = square(15, before.clone()); + if let Some(item) = on_fill.appearance.items.first_mut() { + *item.effects_mut() = vec![fx(three_d, json!({}))]; + } + let projected = art(&on_fill).unwrap(); + assert!(near(&shadows_in(&projected), &[(20.0, 0.0)]), "{three_d} on the fill: {:?}", shadows_in(&projected)); + } +} + #[test] fn glows_blurs_and_feathers_scale_with_the_transform_after_them() { let effects = [ diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 1d39e2b4e4..169cc6102d 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -543,12 +543,14 @@ fn expand_piece_leaves( } /// Path `l` as the canvas paints its fills and strokes: its visible geometry effects applied to its -/// path (a plain path then), and every geometry effect of its own removed. `None` when it has no +/// path (a plain path then), and every geometry effect of its own removed, with each raster effect +/// following the Transform effects after it +/// ([`without_geometry`](vectorcraft_render::effects::without_geometry)). `None` when it has no /// visible geometry effect. The swatches and patterns that live-effect plug-ins painting their /// pieces name are looked up in `doc`. This merges the pieces of such a plug-in into one path, so a /// path that one paints goes through [`expand_pieces`] instead. pub(crate) fn reshaped(doc: &Document, l: &Node) -> Option { - use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, is_geometry}; + use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, without_geometry}; if !has_geometry(&l.appearance.effects) { return None; } @@ -559,7 +561,7 @@ pub(crate) fn reshaped(doc: &Document, l: &Node) -> Option { if carry.moves(&m.appearance) { carry.apply(&mut m.appearance); } - m.appearance.effects.retain(|e| !is_geometry(&e.id)); + m.appearance.effects = without_geometry(&m.appearance.effects); Some(m) } @@ -601,9 +603,10 @@ fn with_path(l: &Node, path: PathData) -> Node { } /// The effects of fill `f` that stay on its expanded art: all but its geometry effects, which -/// shape the outline it paints ([`fill_shape`]). +/// shape the outline it paints ([`fill_shape`]), with each raster effect following the Transform +/// effects after it ([`without_geometry`](vectorcraft_render::effects::without_geometry)). fn fill_effects(f: &FillLayer) -> Vec { - f.effects.iter().filter(|e| !vectorcraft_render::effects::is_geometry(&e.id)).cloned().collect() + vectorcraft_render::effects::without_geometry(&f.effects) } /// Replace every pattern fill of the selected paths by the tiles it paints (see [`expand_patterns`]). diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 5b9e25d354..c617c4de8e 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -896,7 +896,7 @@ pub(crate) fn outlined_stroke( }; let art = d.reid(&art); let mut art = with_transparency(d, art, st.opacity, st.blend); - let raster: Vec<_> = st.effects.iter().filter(|e| !vectorcraft_render::effects::is_geometry(&e.id)).cloned().collect(); + let raster = vectorcraft_render::effects::without_geometry(&st.effects); if !raster.is_empty() { if !art.appearance.effects.is_empty() { art = Node::group(d.alloc_id(), vec![Arc::new(art)]); @@ -946,7 +946,7 @@ pub(crate) fn outlined_stroke( /// geometry effects leave nothing to paint, an empty group takes the path's place. /// `None` when `l` has no stroke to outline. pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &Node) -> Option { - use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_transform_art, is_geometry, reshape}; + use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_transform_art, reshape, without_geometry}; if !l.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st))) { return None; } @@ -975,7 +975,7 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br parts.push(styled_copy(d, l, std::mem::take(&mut fills))); } let mut plain = moved; - plain.effects_mut().retain(|e| !is_geometry(&e.id)); + *plain.effects_mut() = without_geometry(plain.effects()); parts.push(styled_copy(d, &shape, vec![plain])); } AppearanceItem::Stroke(st) if outlinable(st) => { @@ -983,7 +983,7 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br parts.push(styled_copy(d, l, std::mem::take(&mut fills))); } let mut plain = st.clone(); - plain.effects.retain(|e| !is_geometry(&e.id)); + plain.effects = without_geometry(&st.effects); let stroked = match path.bounds() { Some(b) if has_geometry(&st.effects) => apply_geometry_with(&st.effects, &path, b, &ctx.item(item)), _ => path.clone(), diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 84e5559851..da4ade9149 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -191,6 +191,27 @@ fn a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it() { } } +/// A Transform effect after a drop shadow, on the object or on the fill, scales and turns the +/// shadow on the canvas; Object › Expand of the gradient fill keeps the shadow the canvas drew. +#[test] +fn a_shadow_before_a_transform_expands_as_the_canvas_draws_it() { + for item in [json!(null), json!(0)] { + let mut s = session(); + let id = gradient_rect(&mut s, Rect::new(80.0, 80.0, 120.0, 120.0), white_black(), false); + for (effect, params) in [ + ("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 100})), + ("distort.transform", json!({"scaleH": 200, "scaleV": 200, "rotate": 90})), + ] { + run(&mut s, "effect.apply", json!({"effect": effect, "params": params, "ids": [id.0], "item": item})); + } + let before = render(&s); + run(&mut s, "object.expand", json!({"object": false, "stroke": false, "steps": 64})); + let after = render(&s); + let diff = differing(&before, &after, 64); + assert_eq!(diff, 0, "item {item}: {diff} pixels differ"); + } +} + #[test] fn a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it() { // The object's own effect, and the fill's (the first appearance item). diff --git a/crates/engine/src/tests/outlinestroke.rs b/crates/engine/src/tests/outlinestroke.rs index d3394d1481..a92ca10416 100644 --- a/crates/engine/src/tests/outlinestroke.rs +++ b/crates/engine/src/tests/outlinestroke.rs @@ -308,6 +308,30 @@ fn the_outline_of_a_reshaped_stroke_keeps_its_drop_shadow() { assert_eq!(differing(&before, &render(&s), 64), 0, "the shadowed outline draws as the stroke did"); } +/// A Transform effect after a drop shadow, on the object or on the stroke (brushed or not), turns +/// and scales the shadow on the canvas; outlining the stroke keeps the shadow the canvas drew. +#[test] +fn a_shadow_before_a_transform_outlines_as_the_canvas_draws_it() { + let effects = [ + ("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 100})), + ("distort.transform", json!({"scaleH": 50, "scaleV": 50, "rotate": 90})), + ]; + for (item, brush) in [(Value::Null, false), (json!(1), false), (json!(1), true)] { + // Flatten Transparency draws a brushed stroke's drop shadow differently with or without a + // Transform effect, so the brushed stroke is checked with the other two commands. + for (command, p) in outlining().into_iter().filter(|(command, _)| !(brush && *command == "object.flattenTransparency")) { + let mut s = session(); + square_with(&mut s, json!({"color": "#ff0000"}), item.clone(), &effects); + if brush { + s.execute("brush.apply", &json!({"name": "Tapered Stroke"})).unwrap(); + } + let before = render(&s); + outlined_by(&mut s, command, &p); + assert_eq!(differing(&before, &render(&s), 64), 0, "{command}, item {item}, brush {brush}"); + } + } +} + /// The brush art of a brushed stroke with a geometry effect follows the path the effect makes. #[test] fn a_brush_draws_along_the_path_the_stroke_effects_make() { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b20aadde45..53d10f3290 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -589,7 +589,7 @@ since 2026-10-01. - **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. -- **Raster effects before a Transform effect:** effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)), so a Drop Shadow, glow, blur, Feather or pixel filter listed before a Transform effect is transformed with the art, with Scale Strokes & Effects on or off: its sizes scale by the mean scale, and a Drop Shadow's offset turns, reflects and scales with the art. Before, it was drawn at its own distances around the transformed art. The canvas, export and Expand Appearance agree (effects `tests::rasterfollow`, render `a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art`). Not yet: a Transform effect that makes copies or moves at random leaves the raster effects before it as they are, and a Rasterize effect before a Transform effect keeps its pixel grid and room around the art. Scores and hours unchanged. +- **Raster effects before a Transform effect:** effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). A Drop Shadow, glow, blur, Feather or pixel filter listed before a Transform effect in the same list (an object's effects, or a fill's or stroke's own) now follows that Transform effect: its sizes scale by the mean scale, and a Drop Shadow's offset turns, reflects and scales with the art. VectorCraft applies this rule with Scale Strokes & Effects on or off. Before, such an effect was drawn at its own distances around the transformed art. The canvas, export, Expand Appearance, Object › Expand, Outline Stroke and the 3D effects use the same values (effects `tests::rasterfollow`; render `a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art`; engine `objexpand::a_shadow_before_a_transform_expands_as_the_canvas_draws_it`, `outlinestroke::a_shadow_before_a_transform_outlines_as_the_canvas_draws_it`). Not yet: a Transform effect that makes copies or moves at random leaves the raster effects before it as they are; a Rasterize effect before a Transform effect keeps its pixel grid and room around the art; the angles and light directions of pixel filters do not turn or reflect with the art. An object's or group's Transform effect changes the effects of its fills, strokes and members only through Scale Strokes & Effects, as before. Scores and hours unchanged. - **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim. - **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. From ec9a9fd3c9a54e2e63c1dfff359ca82c7b1466ab Mon Sep 17 00:00:00 2001 From: makesomethingshit Date: Thu, 12 Nov 2026 23:44:54 +0900 Subject: [PATCH 25/92] Fix #6865: trust oversized editing artboards with capped PDF pages --- crates/eps/src/import/native/build.rs | 33 ++++++++++++++++++++++++++- crates/eps/src/import/tests_native.rs | 18 +++++++++++++++ docs/file-format-parity.md | 6 ++++- docs/gaps.md | 4 +++- docs/roadmap.md | 9 ++++++-- docs/target-app-parity.md | 8 +++++-- 6 files changed, 71 insertions(+), 7 deletions(-) diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index c86cdb9516..c30086ee68 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -191,6 +191,36 @@ fn left_out_of_comparisons(page: Page, mut restyled: Vec, placeholders: Ve restyled } +/// A single PDF page capped at the format's 14,400-unit limit, without user-space scaling. +/// The other dimension must still match; unrelated page-size mismatches remain errors. +fn capped_pdf_page(doc: &Document, visible: &Document, board: Rect, page: Rect, unit: f64) -> bool { + let capped = |side: f64, pdf: f64| (pdf - 14_400.0).abs() <= 0.5 && side > pdf + 0.5; + let matching = |side: f64, pdf: f64| (side - pdf).abs() <= 0.5 || capped(side, pdf); + unit == 1.0 + && doc.artboards.len() == 1 + && visible.artboards.len() == 1 + && (capped(board.width(), page.width()) || capped(board.height(), page.height())) + && matching(board.width(), page.width()) + && matching(board.height(), page.height()) +} +/// The PDF-covered area of a trusted oversized artboard; its full artboard stays in the document. +fn compared_page_rect( + doc: &Document, + visible: &Document, + board: Rect, + pdf: Rect, + trusted_page: Option, + unit: f64, + warnings: &mut Vec, +) -> Rect { + if matches!(trusted_page, Some(Page::Artboard)) && capped_pdf_page(doc, visible, board, pdf, unit) { + warnings.push("the editing data's artboard is larger than the PDF page: the full artboard comes from its editing data".into()); + Rect::new(board.x0, board.y0, board.x0 + pdf.width(), board.y0 + pdf.height()) + } else { + board + } +} + /// [`build`] from `structure`, the editing copy `data` already read. pub(super) fn build_from( structure: Structure, @@ -222,7 +252,7 @@ pub(super) fn build_from( bindings, bindings_skipped, warped_type, - warnings, + mut warnings, } = structure; let mut restyled = left_out_of_comparisons(page, restyled, placeholders); // Editing data in the units of the page's user space ([`in_units`]), or at its declared large @@ -270,6 +300,7 @@ pub(super) fn build_from( { (rect, cropped) = (art, true); } + rect = compared_page_rect(&doc, visible, rect, page_rect, trust.then_some(page), unit, &mut warnings); if !fits(&rect) { // Both sizes in points as the fields show them, and the editing data's page to the // file's as a scale (1:10 is a tenth of its size) when its sides are in proportion and diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 7353c5c190..74e2c34e3f 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -6940,3 +6940,21 @@ fn text_a_placed_file_covers_only_at_its_first_placement_is_kept() { assert_eq!(text_of(&d.layers[0]).iter().map(|t| t.plain_text()).collect::>(), ["Hi"], "{w:?}"); assert!(!w.iter().any(|w| w.contains("doesn't draw")), "{w:?}"); } + +/// #6865: explicitly trusting editing data keeps an artboard larger than its PDF page. +#[test] +fn trusted_editing_data_keeps_an_artboard_taller_than_its_pdf_page() { + let art = native(&layer("Oversized", true, &square(10, 10))).replace(" 0 0 100 100", " 0 0 12992.726 14637.147"); + let (d, w, read) = crate::layered_ai(&private(&art), Document::new(12992.7, 14400.0), vec![], false, true, 1.0); + assert!(read, "{w:?}"); + assert_eq!(names(&d), ["Oversized"], "{w:?}"); + assert!((d.artboards[0].rect.height() - 14637.147).abs() < 0.001); + assert_eq!(kinds(&d.layers[0]), ["path"]); + assert!(w.iter().any(|w| w.contains("artboard is larger than the PDF page")), "{w:?}"); + let child = &d.layers[0].children().unwrap()[0]; + assert!(child.visual_bounds().unwrap().y0 > 14400.0, "art beyond the PDF page is kept"); + let (_, w, read) = crate::layered_ai(&private(&art), Document::new(12992.7, 14400.0), vec![], false, false, 1.0); + assert!(!read, "default import still rejects the size mismatch: {w:?}"); + let (_, w, read) = crate::layered_ai(&private(&art), Document::new(12000.0, 14400.0), vec![], false, true, 1.0); + assert!(!read, "the uncapped dimension must still match: {w:?}"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b99adf420e..28e2f030f9 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,8 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (#6865: explicitly requested oversized editing artboard; newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> +> Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). 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 @@ -62,6 +64,7 @@ private editing data. VectorCraft: open with different, unconverted RGB colours than before. A PDF-compatible `.ai` opens on its PDF part's trim boxes (its artboards), the bleed boxes round them becoming the document's bleed, so art in the bleed shows outside the artboard, within the bleed guide (#1266). + With editing data explicitly requested, a single artboard larger than its 14,400 pt capped PDF page opens at its full size, with a warning (#6865). A PDF saved with its editing data (`AIPDFPrivateData`) opens the same way. The swatches of a version 7 `.ai` palette, which carry no names, come in named by their spot color, gradient or values. @@ -341,6 +344,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-11 | minor | Newly imported SVG/SVGZ titles use the imported-text rules: generated Untitled names and control-only titles give the filename fallback, other control characters are removed, and titles are limited to 65,536 characters. Native and Preserve Editing titles remain unchanged (#3626). | | 2026-11-11 | minor | Native and Preserve Editing restores keep an existing File Info title exactly, including repeated spaces; imported-text fallback titles alone are normalized (#3626). | | 2026-11-03 | minor | An imported document's own title goes into File Info only (#3626): an SVG's root ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title` is File Info's title (`metadata.title` in the native format, left out when unset so other documents save unchanged and older versions ignore it; written by exports); the document's name, which the tab, Save As and the default export names use, stays the file's name. | diff --git a/docs/gaps.md b/docs/gaps.md index 83afa40942..83d6641445 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (#6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (#6865: oversized editing artboard import; #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -212,6 +212,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi stroke. Other Art brushes (None or another colorization, another width, Proportional, between guides, flipped, art with strokes, gradients or patterns) and type with an Art brush keep the drawn look. +- **Done (2026-11-12):** Explicitly requested editing data retains a single artboard larger than a capped 14,400 pt PDF page (#6865); the default import and other size mismatches remain unchanged. Synthetic importer regression; scores unchanged. - **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead of 14), open speed on large files (#758), live effects, Scatter, Pattern and Bristle brushes on paths, the Art brushes listed above as keeping the drawn look, a path's own Calligraphic or Art brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). A PDF-compatible `.ai` carries an editing copy of the art in the form @@ -858,6 +859,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-03 | minor | An imported document's own title (an SVG's root `<title>`, a PDF's XMP `dc:title` else its Info `Title`) goes into File Info only; the document's name stays the file's name (#3626); no scored gap or estimate reassessed. | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 2026-11-11 | minor | G5: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); Missing trimmed; estimate 5–15 → 4–14 h; MCP Brush libraries list Energy Beams. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 51b0eb31bb..c130b877ee 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,8 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (#6865: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> +> Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -10,6 +12,8 @@ ## Feature submission evidence +M4: Explicitly requested editing data keeps a single artboard larger than its 14,400 pt capped PDF page (#6865). Scores and estimates unchanged. + Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow keys still require focus; modifiers, units, limits, mixed values and existing rounding stay in the shared stepping path. Headless regressions in `tests/numfields.rs` cover hovered unfocused fields, focused fields, trackpad accumulation and panel scroll exclusion. Scores and estimates unchanged. Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. @@ -143,7 +147,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; 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); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 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 copies transformed character dimensions (#6404) and 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 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.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 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; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Explicit editing-data import keeps oversized capped-page artboards (#6865; estimates unchanged); PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.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 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; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | @@ -190,6 +194,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-12 | minor | M12: Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 2026-11-11 | minor | M10/G5: Energy Beams built-in brush library; MCP docs catch-up; estimates unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d42a4d988b..f698e90090 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,8 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (#6865: explicitly requested oversized editing artboard; Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> +> Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > > Live Paint Bucket update (2026-11-12): one drag fills the faces crossed in the same group (#6373). > @@ -503,7 +505,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (artboard escape routing and document setup modal handoff done; Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | | View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 | | Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Smart Guides' angle and distance readouts while drawing, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394; moves snapping to the grid, #740; ruler guides pulling drawn points with Smart Guides on, and "center" and "anchor" labels while hovering with the Selection tool, #812) | 2–4; status-bar X/Y uses the active ruler origin and General units | -| File formats | 6 | 85% | Affinity export, and the rest of Affinity import (adjustments, blur and bevel effects, brushes, live symbol instances, text threading), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) | +| File formats | 6 | 85% | Explicit editing-data oversized single-artboard import fixed (#6865); scores and estimates unchanged. Affinity export, and the rest of Affinity import (adjustments, blur and bevel effects, brushes, live symbol instances, text threading), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) | | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done, as are the Japanese papers in its Media list: JIS B4/B5/B6, はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判, beside the ISO B4/B5 that settings saved before them keep) | 1–2 | | Automation | 3 | 65% | scripting surface, batch (MCP drops malformed app replies without replay; Variables: text/visibility variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; image/graph kinds and dataset import/export open) | 6–10 | @@ -1508,6 +1510,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur `.ai` and EPS editing-data import keeps each path's Attributes panel note (`%AI3_Note:`, `%%Note:`, more lines in `%AI5_NoteMore:`, up to 1,024 characters) and Show Center (`1 Ap`); a note or Show Center applies to every path after it until the next one, and a note before a group or compound path is not theirs (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_illustrator.rs`). `.ai` and EPS editing-data import reads object tags (`/identifier (string) XT`) on the object before them, whatever kind of object it is: `AdobeURL` becomes the object's URL, and every other tag is kept in the object's data in order, up to 64 per object; a tag longer than 64 KiB, and tags in a pattern's or brush's art, are ignored. A tag on art that shows no longer stops the editing data from being read, and brush art that holds tags is read (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_illustrator.rs`). A `.ai` saved without PDF compatibility (its PDF part only a placeholder page) is placed and pasted from its editing data, as it opens: File › Place places the art of the layers that show on the first artboard (`file.place`'s `page` places another one), and Paste of it as PDF data (`clipboard.importPdf`) takes every object. `document.open` with `pages` naming every page of such a file opens it the same way (`crates/engine/src/cmd/fileio/tests_aiimport.rs`). + Explicitly requested editing data keeps the full single artboard when its PDF page is capped at 14,400 pt (#6865; `crates/eps/src/import/tests_native.rs`). Scores and estimates unchanged. A `.ai`, or a PDF saved with Illustrator's editing data, whose PDF part can't be read (a stream the import refuses) opens from its editing data, with the PDF part's error as its first warning; File › Place places its first artboard the same way, and Paste of it as PDF data with `editingData: true` takes every object (part of #4366; `crates/engine/src/cmd/fileio/tests_aiimport.rs`). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part; CCITT Group 3 and planar files with extra channels are not read); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - JPEG XL (`.jxl`) images open and place, linked or embedded, as their first frame, decoded with jxl-oxide and stored as PNG: 16-bit and floating-point samples become 8 bits, alpha is kept, and an image past the pixel allowance is refused from its header (part of #2306; `crates/engine/src/cmd/fileio/tests_jxlread.rs`). @@ -2073,6 +2076,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-03 | minor | File Info on open: an imported document's own title (an SVG's root `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title`) goes into File Info only; the document's name (tab, Save As, default export names) stays the file's name (#3626); scores and estimates unchanged. | | 2026-11-12 | minor | Type: kerning at the Type tool's caret at the start of a line (the text's, a paragraph's, or after a forced line break) sets the space before the character after it (`kerningBefore`), which moves it and the rest of its line; it did nothing at the text's start and kerned the invisible line break at a paragraph's (#5111). Scores and estimates unchanged. | | 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | From 5036099b51cb083edcc516e4d9ebd46ab4d06944 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 06:57:26 -0800 Subject: [PATCH 26/92] Effects: a copying Transform effect after 3D holds raster effects back; pixel filter sizes follow - A Transform effect that makes copies or moves at random, listed after an Extrude or Revolve effect, now keeps the raster effects before the 3D effect at their own values, as it does without the 3D effect. Before, the 3D art kept a Drop Shadow scaled by a Transform effect between them. - In a fill's or stroke's own effects, such a Transform effect also keeps the raster effects before it from following an earlier Transform effect with Transform Patterns on, and the Transform effects between them. - Colored Pencil's pencilWidth and Paint Daubs' brushSize are distances in document units, so a Transform effect after them and Scale Strokes & Effects scale them as they scale Palette Knife's strokeSize. The Paint Daubs params text names the unit. --- crates/effects/src/catalog.rs | 4 +- crates/effects/src/reshape.rs | 22 ++++++++--- crates/effects/src/solids.rs | 11 +++++- crates/effects/src/tests/rasterfollow.rs | 48 ++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 8 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 8edea9d535..9f62024150 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -94,6 +94,8 @@ fn lengths_of(id: &str) -> Lengths { "stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]), "brushStrokes.accentedEdges" => always(&["edgeWidth"]), "artistic.paletteKnife" => always(&["strokeSize"]), + "artistic.coloredPencil" => always(&["pencilWidth"]), + "artistic.paintDaubs" => always(&["brushSize"]), "brushStrokes.angledStrokes" | "brushStrokes.crosshatch" | "brushStrokes.inkOutlines" => always(&["strokeLength"]), "brushStrokes.spatter" => always(&["sprayRadius"]), "brushStrokes.sprayedStrokes" => always(&["strokeLength", "sprayRadius"]), @@ -387,7 +389,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> { "artistic.paintDaubs", "Paint Daubs…", ARTISTIC, - "{brushSize: 1..50 (10; dab centres farther apart), sharpness: 0..40 (25; harder dab edges), brushType: \"simple\"|\"lightRough\"|\"darkRough\"|\"wideSharp\"|\"wideBlurry\"|\"sparkle\" (\"simple\")} repaints the object with thick round brush dabs in the local colours, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + "{brushSize: 1..50 (10; spacing of the dab centers in document units),sharpness: 0..40 (25; harder dab edges), brushType: \"simple\"|\"lightRough\"|\"darkRough\"|\"wideSharp\"|\"wideBlurry\"|\"sparkle\" (\"simple\")} repaints the object with thick round brush dabs in the local colours, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", json!({"brushSize": 10.0, "sharpness": 25.0, "brushType": "simple"}), ), r( diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index b25c016cfd..6ddc4b8c0e 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -1263,9 +1263,13 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt let params = merged_params(&fx[at].id, &fx[at].params); // Where each effect the piece keeps sits among the item's effects. let kept: Vec<usize> = (0..at).filter(|&k| !is_geometry(&fx[k].id)).chain(at + 1..fx.len()).collect(); + // A Transform effect after this one that makes copies or moves at random holds the raster + // effects before this one back from every Transform effect ([`crate::raster::followed`]). + let held_later = fx.get(at + 1..).unwrap_or_default().iter().any(crate::raster::holds_back); // The effects before the Transform effect that it keeps, each raster effect following the - // geometry effects before it ([`crate::raster::without_geometry`]). - let kept_before = crate::without_geometry(before); + // geometry effects before it ([`crate::raster::without_geometry`]) unless held back. + let kept_before: Vec<Effect> = + if held_later { before.iter().filter(|e| !is_geometry(&e.id)).cloned().collect() } else { crate::without_geometry(before) }; if let Some(plugin) = painted_plugin(&fx[at]) { let mut it = item.clone(); let rest: Vec<Effect> = kept_before.iter().chain(&fx[at + 1..]).cloned().collect(); @@ -1308,7 +1312,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt let (made, moved) = transform_leaf(&mut p, &g, map, copies, options, room); *used = used.plus(made); let from = crate::raster::held_back(before); - if moved { + if moved && !held_later { follow_piece(&mut p, kept_before.get(from..).unwrap_or_default(), from, &fx[at]); } transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(&fx[..=at]), limit, used, doc); @@ -1333,7 +1337,8 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt /// A live-effect plug-in that paints what it returns ([`painted_plugin`]) makes each path one path /// for each piece it returns ([`paint_piece`]). The raster effects before a Transform effect /// follow it ([`follow_piece`]), except the first `held` effects, which a Transform effect before -/// the piece's effects that makes copies or moves at random holds back. The geometry effects then +/// the piece's effects that makes copies or moves at random holds back, and the effects before +/// such a Transform effect among the piece's effects. The geometry effects then /// leave the fills and strokes; their other effects stay. The paths keep to `max` together with /// the rest of the art (`used`), and the swatches and patterns painted pieces name must be in /// `doc`. `(seed, source, kept)`: the object's seed, the index in the object's appearance of the @@ -1346,7 +1351,14 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), h count = count.max(item.effects().len()); } }); + // The effects before a Transform effect among the piece's effects that makes copies or moves at + // random are held back from the Transform effects before it too. let mut held = held; + for_each_leaf(p, &mut |l| { + if let Some(last) = l.appearance.items.first().and_then(|item| item.effects().iter().rposition(crate::raster::holds_back)) { + held = held.max(last); + } + }); for j in 0..count { let Some(bounds) = p.geometric_bounds() else { break }; let from = held.min(j); @@ -1397,7 +1409,7 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), h follow_piece(leaf, was, from, &e); }); if holds { - held = j; + held = held.max(j); } } for_each_leaf(p, &mut |leaf| { diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 4d04a4009a..f71d79fb60 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -961,10 +961,17 @@ fn group_solid_art(n: &Node) -> Option<Node> { /// `effects` without the 3D effect at `index` and the geometry effects before it, which shape the /// art it projects. Each raster effect before it follows the Transform effects among those -/// ([`crate::without_geometry`]); the effects after it stay as they are. +/// ([`crate::without_geometry`]), unless a Transform effect after it makes copies or moves at +/// random ([`crate::raster::holds_back`]); the effects after it stay as they are. pub(crate) fn past_effect(effects: &[Effect], index: usize) -> Vec<Effect> { let (before, after) = effects.split_at(index.min(effects.len())); - crate::without_geometry(before).into_iter().chain(after.iter().skip(1).cloned()).collect() + let after = after.get(1..).unwrap_or_default(); + let kept: Vec<Effect> = if after.iter().any(crate::raster::holds_back) { + before.iter().filter(|e| !is_geometry(&e.id)).cloned().collect() + } else { + crate::without_geometry(before) + }; + kept.into_iter().chain(after.iter().cloned()).collect() } /// One solid of a 3D object as a mesh, with the color it is painted. diff --git a/crates/effects/src/tests/rasterfollow.rs b/crates/effects/src/tests/rasterfollow.rs index 5d578d15a8..875d916940 100644 --- a/crates/effects/src/tests/rasterfollow.rs +++ b/crates/effects/src/tests/rasterfollow.rs @@ -264,6 +264,54 @@ fn glows_blurs_and_feathers_scale_with_the_transform_after_them() { assert_eq!(crate::outset(&shadowed, Rect::new(0.0, 0.0, 100.0, 100.0)), 20.0); } +#[test] +fn pixel_filter_sizes_scale_with_the_transform_after_them() { + let effects = [ + fx("artistic.coloredPencil", json!({"pencilWidth": 5})), + fx("artistic.paintDaubs", json!({"brushSize": 10})), + transform(json!({"scaleH": 200, "scaleV": 200})), + ]; + let drawn = raster_effects(&effects); + assert!(matches!(drawn.first(), Some(RasterFx::Pixel(crate::PixelFx::ColoredPencil { width, .. })) if (width - 10.0).abs() < 1e-9), "{drawn:?}"); + assert!(matches!(drawn.get(1), Some(RasterFx::Pixel(crate::PixelFx::PaintDaubs { size, .. })) if (size - 20.0).abs() < 1e-9), "{drawn:?}"); +} + +/// A Transform effect after a 3D effect that makes copies or moves at random holds back the shadow +/// before the 3D effect, as it does without the 3D effect. +#[test] +fn a_copying_transform_after_3d_holds_the_shadow_back() { + for later in [json!({"copies": 1}), json!({"scaleH": 150, "scaleV": 150, "random": true})] { + let plain = [shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200})), transform(later.clone())]; + assert!(close(drawn_shadow(&plain), (10.0, 0.0, 0.0)), "{later}: {:?}", drawn_shadow(&plain)); + for three_d in [crate::EXTRUDE, crate::REVOLVE] { + let art = |n: &Node| if three_d == crate::EXTRUDE { crate::solid_art(n) } else { crate::revolve_art(n) }; + let effects = vec![plain[0].clone(), plain[1].clone(), fx(three_d, json!({})), plain[2].clone()]; + let projected = art(&square(16, effects)).unwrap(); + let drawn = shadows_in(&projected); + assert!(!drawn.is_empty() && near(&drawn, &vec![(10.0, 0.0); drawn.len()]), "{three_d} {later}: {drawn:?}"); + } + } +} + +/// In a fill's own effects, a Transform effect that makes copies after a Transform effect with +/// Transform Patterns on holds back the shadow before both, and before a Transform effect between +/// them. +#[test] +fn a_fills_copying_transform_holds_back_the_shadow_before_an_earlier_transform() { + let patterns = transform(json!({"scaleH": 200, "scaleV": 200, "patterns": true})); + let copies = transform(json!({"copies": 1})); + let lists = [ + vec![shadow(10.0, 0.0, 0.0), patterns.clone(), copies.clone()], + vec![shadow(10.0, 0.0, 0.0), patterns, transform(json!({"rotate": 90})), copies], + ]; + for effects in lists { + let n = fill_square(17, effects.clone()); + let art = crate::reshape(None, &n, None).unwrap(); + let drawn = shadows_in(&art); + assert!(!drawn.is_empty() && near(&drawn, &vec![(10.0, 0.0); drawn.len()]), "{} effects: {drawn:?}", effects.len()); + } +} + #[test] fn expanded_and_exported_objects_keep_the_shadow_the_canvas_draws() { let effects = vec![shadow(10.0, 10.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 200}))]; From a1ece0052b7a7ae1aca58dd395bd2499d6c9cd25 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 11:26:25 -0800 Subject: [PATCH 27/92] MCP: hidden library ids name their library in requests and are rewritten only as whole ids Follows #6608. ui.inspect shows a library loaded from outside the read roots as loaded/<file name>. A request may now name the library or its panel by that id in a panel, library or panels param, in a docking destination (anchor, before, onto, above, below) and in window.panel.layout's action: ui.set {panel} reopens the closed panel instead of floating a new one. The reply to such a request, an error included, shows the shown id. ui.inspect rewrites only whole ids, and only in the fields that hold library and panel ids, so a readable library's id that begins with a hidden one keeps its path, and status text, the command palette query and a script's report keep their text. A hidden library does not take the id of a library loaded without a path. ui.inspect also leaves out of recorded action steps the path and folder params, params named like destinationFolder and the entries of a paths list that lie outside the read roots, also inside a batch step's commands. It hides a library id holding a path written with either separator, and checks a folder preference against the roots as written, without trimming spaces. --- crates/ui-egui/src/control.rs | 221 ++++++++++++++++++++++--- crates/ui-egui/src/tests/automation.rs | 183 ++++++++++++++++++++ docs/mcp.md | 22 ++- 3 files changed, 399 insertions(+), 27 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index ef6a6d20c8..d0be1a13ef 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -309,10 +309,22 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { "fileDialog": app.picks.waiting_kind(), }); let ids = unreadable_library_ids(app); - hide_library_ids(&mut inspected, &ids); + for field in PANEL_FIELDS.iter().chain(&LIBRARY_ID_FIELDS) { + if let Some(v) = inspected["ui"].get_mut(*field) { + hide_library_ids(v, &ids); + } + } + let pairs: Vec<_> = ids.iter().map(|(id, shown)| (id.as_str(), shown.as_str())).collect(); + if let Some(sets) = inspected["ui"].get_mut("action_sets") { + swap_library_params(sets, &pairs); + } inspected } +/// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold library ids or panel +/// ids besides [`PANEL_FIELDS`]: the library panels, the Color Guide's limit and the workspaces. +const LIBRARY_ID_FIELDS: [&str; 4] = ["library_panels", "color_guide_limit", "custom_workspaces", "workspace_changes"]; + /// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold panel ids: the panel /// popped out of the collapsed column, the docking layout, closed panels' places and groups, the panels /// shown as icons or collapsed, and the older floating panels' record. @@ -322,9 +334,10 @@ const PANEL_FIELDS: [&str; 7] = /// The ids of the loaded libraries (`loaded/<path>`) that the UI state refers to and whose file the /// request may not read, each with the id a reply shows instead: `loaded/<file name>`, as a library /// loaded without a path is named. The UI state refers to a library by its bare id in a library panel -/// and the Color Guide's limit, and by a panel id (`library:<kind>:loaded/<path>`) in the fields of -/// [`PANEL_FIELDS`] and the workspaces' layouts. Text the person types (a command palette query, a -/// workspace's name) is not read. The ids are numbered with the session's loaded libraries as the +/// and the Color Guide's limit, by a panel id (`library:<kind>:loaded/<path>`) in the fields of +/// [`PANEL_FIELDS`] and the workspaces' layouts, and by either in the `library` and `panel` params of +/// recorded actions' steps. Text the person types (a command palette query, a workspace's or an +/// action's name) is not read. The ids are numbered with the session's loaded libraries as the /// library commands number them ([`vectorcraft_engine::cmd::swatchlib::concealed_library_ids`]), so /// that two libraries stay two and `ui.inspect`, `ui.docking` and the library commands show a library /// under the same id. @@ -334,6 +347,7 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { for field in PANEL_FIELDS { collect_library_panel_ids(&ui[field], &mut ids); } + collect_library_params(&ui["action_sets"], &mut ids); for workspace in ["custom_workspaces", "workspace_changes"].iter().filter_map(|k| ui[k].as_array()).flatten() { for (_, layout) in workspace.as_object().into_iter().flatten().filter(|(k, _)| k.as_str() != "name") { collect_library_panel_ids(layout, &mut ids); @@ -382,30 +396,81 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet<Str /// Writes each id of `ids` as the id the reply shows instead, wherever `v` holds it: in a library's /// id, in its panel's id (`library:swatches:loaded/<path>`) and in keys. fn hide_library_ids(v: &mut Value, ids: &[(String, String)]) { - if ids.is_empty() { + let pairs: Vec<_> = ids.iter().map(|(id, shown)| (id.as_str(), shown.as_str())).collect(); + swap_library_ids(v, &pairs); +} + +/// Text that a person or a script wrote, by its key: a command palette query, a script's report and a +/// workspace's name. [`swap_library_ids`] leaves it as written. +const TEXT_FIELDS: [&str; 3] = ["palette_query", "scriptReport", "name"]; + +/// Writes each `from` id of `pairs` as its `to` id wherever `v` holds it as a whole id ([`swap_whole_ids`]), +/// in strings and keys, except in the [`TEXT_FIELDS`]. +fn swap_library_ids(v: &mut Value, pairs: &[(&str, &str)]) { + if pairs.is_empty() { return; } - let hide = |s: &mut String| { - for (id, shown) in ids { - if s.contains(id.as_str()) { - *s = s.replace(id.as_str(), shown); + match v { + Value::String(s) => { + if let Some(swapped) = swap_whole_ids(s, pairs) { + *s = swapped; } } - }; - match v { - Value::String(s) => hide(s), - Value::Array(items) => items.iter_mut().for_each(|item| hide_library_ids(item, ids)), + Value::Array(items) => items.iter_mut().for_each(|item| swap_library_ids(item, pairs)), Value::Object(fields) => { - for (mut key, mut item) in std::mem::take(fields) { - hide(&mut key); - hide_library_ids(&mut item, ids); - fields.insert(key, item); + for (key, mut item) in std::mem::take(fields) { + if TEXT_FIELDS.contains(&key.as_str()) { + fields.insert(key, item); + continue; + } + swap_library_ids(&mut item, pairs); + fields.insert(swap_whole_ids(&key, pairs).unwrap_or(key), item); } } _ => {} } } +/// `s` with each `from` id of `pairs` written as its `to` id where it is a whole id: the whole string, +/// or a part that starts the string or follows a `:`, `"` or `` ` `` (a panel id, a quoted id) and ends +/// the string or a quote. An id that only begins or ends a longer one stays. `None` when nothing changed. +fn swap_whole_ids(s: &str, pairs: &[(&str, &str)]) -> Option<String> { + let mut out: Option<String> = None; + for (from, to) in pairs { + let text = out.as_deref().unwrap_or(s); + if !text.contains(from) { + continue; + } + let mut next = String::with_capacity(text.len()); + let mut copied = 0; + for (at, _) in text.match_indices(from) { + let end = at + from.len(); + let starts = at == 0 || text[..at].ends_with([':', '"', '`']); + let ends = end == text.len() || text[end..].starts_with(['"', '`']); + if at >= copied && starts && ends { + next.push_str(&text[copied..at]); + next.push_str(to); + copied = end; + } + } + if copied > 0 { + next.push_str(&text[copied..]); + out = Some(next); + } + } + out +} + +/// Does `v` hold a string or a key that names a loaded library (`loaded/…`)? +fn names_a_loaded_library(v: &Value) -> bool { + match v { + Value::String(s) => s.contains("loaded/"), + Value::Array(items) => items.iter().any(names_a_loaded_library), + Value::Object(fields) => fields.iter().any(|(key, item)| key.contains("loaded/") || names_a_loaded_library(item)), + _ => false, + } +} + /// The preferences that hold a folder, by their keys in `ui.engine_prefs` and the Preferences dialog. const PREF_FOLDERS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"]; @@ -419,9 +484,43 @@ fn readable(path: &str) -> bool { vectorcraft_engine::file_access::check_read(path).is_ok() } +/// Does a recorded step's param `key` hold a file or folder path: `path`, `folder` or a name ending in +/// `Folder` (`destinationFolder`, `searchFolder`)? +fn is_path_param(key: &str) -> bool { + matches!(key, "path" | "folder") || key.ends_with("Folder") +} + +/// The params of each step of each action of action set `set` (`ui.action_sets`). +fn steps_mut(set: &mut Value) -> impl Iterator<Item = &mut serde_json::Map<String, Value>> { + let actions = set.get_mut("actions").and_then(Value::as_array_mut).into_iter().flatten(); + let steps = actions.filter_map(|action| action.get_mut("steps").and_then(Value::as_array_mut)).flatten(); + steps.filter_map(|step| step.get_mut(1).and_then(Value::as_object_mut)) +} + +/// Takes out of a recorded step's params, at any depth (the commands of a `command.batch` step), the +/// paths the request may not read: a path param ([`is_path_param`]) and each path of a `paths` list. +fn leave_out_unreadable_params(params: &mut serde_json::Map<String, Value>) { + params.retain(|key, v| !(is_path_param(key) && v.as_str().is_some_and(|p| !readable(p)))); + if let Some(Value::Array(paths)) = params.get_mut("paths") { + paths.retain(|p| p.as_str().is_none_or(readable)); + } + for v in params.values_mut() { + leave_out_unreadable_values(v); + } +} + +/// [`leave_out_unreadable_params`] on each object `v` holds. +fn leave_out_unreadable_values(v: &mut Value) { + match v { + Value::Object(fields) => leave_out_unreadable_params(fields), + Value::Array(items) => items.iter_mut().for_each(leave_out_unreadable_values), + _ => {} + } +} + /// Takes out of `ui.inspect`'s copy of the UI state the paths the request may not read: recent files, -/// a library panel's file and folder preferences (saved, and in an open Preferences dialog) outside -/// the read root. The UI state itself is not changed. +/// a library panel's file, folder preferences (saved, and in an open Preferences dialog) and the paths +/// in recorded actions' steps outside the read root. The UI state itself is not changed. fn leave_out_unreadable_paths(ui: &mut serde_json::Map<String, Value>) { if let Some(Value::Array(files)) = ui.get_mut("recent_files") { files.retain(|f| f.as_str().is_some_and(readable)); @@ -433,6 +532,9 @@ fn leave_out_unreadable_paths(ui: &mut serde_json::Map<String, Value>) { } } } + for step in ui.get_mut("action_sets").and_then(Value::as_array_mut).into_iter().flatten().flat_map(steps_mut) { + leave_out_unreadable_params(step); + } if let Some(Value::Object(prefs)) = ui.get_mut("engine_prefs") { leave_out_unreadable_folders(prefs); } @@ -448,8 +550,9 @@ fn leave_out_unreadable_paths(ui: &mut serde_json::Map<String, Value>) { /// empty value (no folder) and a scratch disk choice ([`SCRATCH_CHOICES`]) stay. fn leave_out_unreadable_folders(prefs: &mut serde_json::Map<String, Value>) { for key in PREF_FOLDERS { - let Some(path) = prefs.get(key).and_then(Value::as_str).map(str::trim) else { continue }; - if path.is_empty() || (key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path)) || readable(path) { + let Some(written) = prefs.get(key).and_then(Value::as_str) else { continue }; + let path = written.trim(); + if path.is_empty() || (key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path)) || readable(written) { continue; } prefs.remove(key); @@ -494,7 +597,83 @@ fn packet_mods(value: Option<&Value>) -> Result<Option<Mods>, String> { } } +/// The params that name a library or a library panel, at any depth of a request (`ui.set {panel}`, +/// `engine.execute {params: {panel}}`, `ui.libraryPanel {panel, library}`), with the docking +/// commands' destinations (`window.panel.move {anchor, before}`, `window.panel.float {onto, above, +/// below}`) and a list of panels. +const LIBRARY_PARAMS: [&str; 8] = ["panel", "library", "anchor", "before", "onto", "above", "below", "panels"]; + +/// The param of `window.panel.layout` that holds a serialized docking action: each panel id in it is +/// translated. +const DOCKING_ACTION: &str = "action"; + +/// Runs a control request. A library loaded from outside the read root keeps the id `ui.inspect` +/// shows for it ([`unreadable_library_ids`]) both ways: a request may name it or its panel by that id +/// in a [`LIBRARY_PARAMS`] param, and the reply to such a request shows that id. pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { + let ids = if names_a_loaded_library(&req.params) { unreadable_library_ids(app) } else { Vec::new() }; + let shown: Vec<_> = ids.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect(); + let mut params = req.params.clone(); + if shown.is_empty() || !swap_library_params(&mut params, &shown) { + return dispatch(app, ctx, req); + } + let named = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq }; + match dispatch(app, ctx, &named) { + Outcome::Done(mut reply) => { + hide_library_ids(&mut reply, &ids); + Outcome::Done(reply) + } + outcome => outcome, + } +} + +/// Writes each `from` id of `pairs` in the [`LIBRARY_PARAMS`] params of `params`, at any depth, as its +/// `to` id → whether one was. +fn swap_library_params(params: &mut Value, pairs: &[(&str, &str)]) -> bool { + match params { + Value::Array(items) => items.iter_mut().fold(false, |swapped, item| swap_library_params(item, pairs) | swapped), + Value::Object(fields) => fields.iter_mut().fold(false, |swapped, (key, item)| { + let this = match item { + Value::String(s) if LIBRARY_PARAMS.contains(&key.as_str()) => swap_whole_ids(s, pairs).map(|to| *s = to).is_some(), + Value::Array(items) if LIBRARY_PARAMS.contains(&key.as_str()) => items.iter_mut().fold(false, |swapped, item| { + let this = item.as_str().and_then(|s| swap_whole_ids(s, pairs)).map(|to| *item = Value::String(to)).is_some(); + this | swapped + }), + Value::Object(_) if key == DOCKING_ACTION => { + let before = item.clone(); + swap_library_ids(item, pairs); + *item != before + } + _ => swap_library_params(item, pairs), + }; + this | swapped + }), + _ => false, + } +} + +/// Adds to `ids` the loaded library ids that the [`LIBRARY_PARAMS`] params of `v` name, at any depth: +/// a library's id or its panel's id. +fn collect_library_params(v: &Value, ids: &mut std::collections::BTreeSet<String>) { + match v { + Value::Array(items) => items.iter().for_each(|item| collect_library_params(item, ids)), + Value::Object(fields) => { + for (key, item) in fields { + if !LIBRARY_PARAMS.contains(&key.as_str()) { + collect_library_params(item, ids); + continue; + } + for s in item.as_str().into_iter().chain(item.as_array().into_iter().flatten().filter_map(Value::as_str)) { + add_library_id(s, ids); + collect_library_panel_ids(&json!(s), ids); + } + } + } + _ => {} + } +} + +fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); match req.method.as_str() { diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 3a84e0b94d..8f6e0f5ab5 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -473,6 +473,124 @@ fn ui_inspect_and_library_commands_show_a_hidden_library_under_one_id() { let _ = std::fs::remove_dir_all(base); } +/// `ui.inspect` checks a folder preference as it is written: the read root's path with a space before +/// it is a relative path, outside the root, and is left out. +#[test] +fn ui_inspect_checks_a_folder_preference_as_written() { + let (mut app, tx, base) = confined("inspect-folder-space"); + let inside = s(base.join("in")); + app.ui.engine_prefs = json!({"fontsFolder": inside, "templatesFolder": format!(" {inside}")}); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let prefs = r["result"]["ui"]["engine_prefs"].as_object().unwrap(); + assert_eq!(prefs.get("fontsFolder").and_then(Value::as_str), Some(inside.as_str()), "{r}"); + assert!(!prefs.contains_key("templatesFolder"), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` rewrites a hidden library's id only in the fields that hold library and panel ids: +/// status text that is that id stays as written. +#[test] +fn ui_inspect_keeps_status_text_that_is_a_hidden_library_id() { + let (mut app, tx, base) = confined("inspect-status-id"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + app.ui.color_guide_limit = theirs.clone(); + app.ui.status = theirs.clone(); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}"); + assert_eq!(r["result"]["ui"]["status"], json!(theirs), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` hides a library loaded from outside the read root in the steps of a recorded action +/// (its `library` and `panel` params), also when nothing else names it, and keeps the action's name. +#[test] +fn ui_inspect_hides_a_hidden_library_in_recorded_steps() { + let (mut app, tx, base) = confined("inspect-action-steps"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ + "name": "set", + "actions": [{"name": theirs, "steps": [ + ["swatch.library.add", {"library": theirs}], + ["window.panel", {"panel": format!("library:swatches:{theirs}")}], + ]}], + })) + .unwrap(); + app.ui.action_sets = vec![set]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let action = &r["result"]["ui"]["action_sets"][0]["actions"][0]; + assert_eq!(action["name"], json!(theirs), "{r}"); + assert_eq!( + action["steps"], + json!([["swatch.library.add", {"library": "loaded/b.svg"}], ["window.panel", {"panel": "library:swatches:loaded/b.svg"}]]), + "{r}" + ); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` leaves out of a recorded action's steps the file paths (`path`, `folder`, a name ending +/// in `Folder`, `paths`) outside the read root, also in a batch step's commands, and keeps the ones +/// inside it. +#[test] +fn ui_inspect_leaves_out_recorded_paths_outside_the_read_root() { + let (mut app, tx, base) = confined("inspect-action-paths"); + let (a, b) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg"))); + let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ + "name": "set", + "actions": [{"name": "files", "steps": [ + ["file.open", {"path": a}], + ["file.open", {"path": b}], + ["file.place", {"paths": [a, b]}], + ["batch.run", {"folder": s(base.join("outside"))}], + ["action.batch", {"destinationFolder": s(base.join("outside"))}], + ["command.batch", {"commands": [{"command": "file.open", "params": {"path": b}}]}], + ]}], + })) + .unwrap(); + app.ui.action_sets = vec![set]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"]; + let want = json!([ + ["file.open", {"path": a}], + ["file.open", {}], + ["file.place", {"paths": [a]}], + ["batch.run", {}], + ["action.batch", {}], + ["command.batch", {"commands": [{"command": "file.open", "params": {}}]}], + ]); + assert_eq!(steps, &want, "{r}"); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 6, "the UI state is not changed"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` hides a library loaded from outside the read root whose id holds a path written with +/// either separator, also one saved on another platform. +#[test] +fn ui_inspect_hides_a_library_path_with_either_separator() { + let (mut app, tx, base) = confined("inspect-separators"); + app.ui.color_guide_limit = r"loaded/C:\Users\someone\b.svg".to_string(); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}"); + app.ui.color_guide_limit = "loaded//home/someone/c.svg".to_string(); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/c.svg", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A hidden library does not take the id of a library loaded without a path under the same file +/// name: it shows as `loaded/<file name> (2)`, and a request that names the other library reaches it. +#[test] +fn ui_inspect_keeps_a_hidden_library_off_a_loaded_librarys_id() { + let (mut app, tx, base) = confined("inspect-loaded-id"); + let svg = std::fs::read_to_string(base.join("in/a.svg")).unwrap(); + let loaded = app.session.execute("swatch.library.load", &json!({"data": svg, "name": "b.svg"})).unwrap(); + assert_eq!(loaded["library"], "loaded/b.svg"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + app.ui.color_guide_limit = theirs; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg (2)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A closed panel's former place, as `ui.docking_hidden` keeps it. fn hidden_place() -> craft_ui::docking::Location<String> { serde_json::from_value(json!({"floating": null, "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null})).unwrap() @@ -611,6 +729,71 @@ fn ui_inspect_script_report_leaves_out_files_outside_the_read_root() { let _ = std::fs::remove_dir_all(base); } +/// `ui.inspect` rewrites a hidden library's id only where a string is that whole id: a readable +/// library's id that begins with it stays, and a script's report line and a command palette query +/// that hold it stay as written. +#[test] +fn ui_inspect_rewrites_only_whole_hidden_library_ids() { + let (mut app, tx, base) = confined("inspect-whole-id"); + let hidden = format!("loaded/{}", s(base.join("i"))); + let readable = format!("loaded/{}", s(base.join("in/a.svg"))); + assert!(readable.starts_with(&hidden)); + let panel = |id: &str| -> crate::panels::library_panel::LibraryPanel { serde_json::from_value(json!({"kind": "swatches", "id": id})).unwrap() }; + app.ui.library_panels = vec![panel(&hidden), panel(&readable)]; + let query = format!("library:swatches:{hidden}"); + app.ui.palette_query = query.clone(); + let text = format!("found {hidden}"); + let source = format!("vc.log({})", json!(text)); + app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); + for _ in 0..10_000 { + if app.session.script_pump().is_some() { + break; + } + } + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let ids: Vec<_> = r["result"]["ui"]["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect(); + assert_eq!(ids, [json!("loaded/i"), json!(readable)], "{r}"); + assert_eq!(r["result"]["scriptReport"]["log"], json!([text]), "{r}"); + assert_eq!(r["result"]["ui"]["palette_query"], json!(query), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A client can name a hidden library by the id `ui.inspect` shows: `ui.set {panel}` with that panel id +/// reopens the closed panel of the library, and the reply does not show its path. +#[test] +fn ui_set_reopens_a_hidden_librarys_panel_by_its_shown_id() { + let (mut app, tx, base) = confined("set-hidden-panel"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + let real = format!("library:swatches:{theirs}"); + app.ui.docking_hidden.insert(real.clone(), hidden_place()); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let shown = "library:swatches:loaded/b.svg"; + assert!(r["result"]["ui"]["docking_hidden"].as_object().unwrap().contains_key(shown), "{r}"); + let r = request(&mut app, &tx, "ui.set", json!({"panel": shown})); + assert_eq!(r["ok"], true, "{r}"); + assert!(!app.ui.docking_hidden.contains_key(&real), "the closed panel is reopened: {r}"); + let layout = crate::panel_docking::layout_of(&app.ui); + assert!(layout.contains(&real) && !layout.contains(&shown.to_string()), "no second panel: {layout:?}"); + let hidden = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + // A docking command takes the shown id as its destination too. + let r = request( + &mut app, + &tx, + "engine.execute", + json!({"command": "window.panel.move", "params": {"panel": "layers", "anchor": shown, "zone": "tab"}}), + ); + assert_eq!(r["ok"], true, "{r}"); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + // An error that quotes the panel shows the id the client gave, and other text is not rewritten. + let r = request(&mut app, &tx, "ui.set", json!({"panel": "library:bogus:loaded/b.svg"})); + assert_eq!(r["ok"], false, "{r}"); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + let r = request(&mut app, &tx, "ui.set", json!({"status": "loaded/b.svg"})); + assert_eq!((r["ok"].as_bool(), app.ui.status.as_str()), (Some(true), "loaded/b.svg"), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A client without automation roots sees every loaded library's full id in `ui.inspect`. #[test] fn ui_inspect_shows_full_library_ids_without_a_read_root() { diff --git a/docs/mcp.md b/docs/mcp.md index 996908799f..ac035e7cfc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -143,15 +143,25 @@ the suite) keep the agent to the folders you give it: `file.openRecentN` for a file outside the roots ends with `no such recent file`, as an empty slot does. - `ui_inspect` (the control channel's `ui.inspect`) leaves out the paths outside the read roots: in `opening`, the documents still being read; in `ui.recent_files`, the recent files; a library panel's `path` in `ui.library_panels`; - and the folder preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` + in a recorded action's steps in `ui.action_sets`, also in a batch step's commands, the `path` and `folder` params, + a param whose name ends in `Folder` (`destinationFolder`) and the entries of a `paths` param; and the folder + preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`; an empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the roots shows as `loaded/<file name>` wherever `ui_inspect` and `ui.docking` name it: its id, its panel's id, the - Color Guide's `ui.color_guide_limit` and a closed panel's key in `ui.docking_hidden`, also when its panel is - closed. When another hidden library has the same file name, the libraries take `loaded/<file name>`, - `loaded/<file name> (2)`, `(3)` and so on in the order of their full ids (the libraries loaded in this session - first, then those only the UI state names), and each one keeps its id in both replies and in the library - commands below. The other entries keep their order, and the app's own state is not changed. + Color Guide's `ui.color_guide_limit`, a closed panel's key in `ui.docking_hidden` and the `library` and `panel` + params of a recorded action's steps in `ui.action_sets`, also when its panel is closed. A path written with + either separator counts as a path, also one saved on another platform. When another hidden library has the same + file name, the libraries take `loaded/<file name>`, `loaded/<file name> (2)`, `(3)` and so on in the order of + their full ids (the libraries loaded in this session first, then those only the UI state names), and each one + keeps its id in both replies and in the library commands below. A hidden library skips an id that a library + loaded without a path already has. The other entries keep their order, and the app's own state is not + changed. A request may name such a library or its panel by the id + shown in a `panel`, `library` or `panels` param, in a docking destination (`anchor`, `before`, `onto`, `above`, + `below`) and in `window.panel.layout`'s `action` (`ui.set {panel: "library:swatches:loaded/<file name>"}` + reopens its panel), and the reply to such a request, an error included, shows that id too. `ui_inspect` rewrites only whole ids, and only in the fields that hold library and panel ids: + a longer id that begins with a hidden one, the status text (`ui.status`), the command palette query + (`ui.palette_query`) and a script's report (`scriptReport`) keep their text. - `swatch.library.list`, `brush.libraries`, `symbol.libraries` and `graphicStyle.libraries` list every library but leave out the paths outside the read roots: a library's `path` and the user library folder in `userFolder` are `null` there. A library loaded from a file outside the roots is listed as `loaded/<file name>`, numbered From a2c08dec2724b06fd421dd0cffd1c9fb318c6e63 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 13:39:28 -0800 Subject: [PATCH 28/92] MCP: replies hide a library path outside the read roots wherever it appears Under the read roots, every control reply now shows a hidden library's id as loaded/<file name> and its path as the file name in every string and key: status text, dialogs, the command palette query, a script's report, recorded steps and errors. The id and path are replaced at any boundary, so a path between spaces, brackets or quotes, followed by a period or by more path text (<path>.bak), or quoted with escaped backslashes is hidden too. An occurrence that continues into a longer path stays only when the request may read that longer path, so a readable library's id that begins with a hidden one keeps its text. The libraries hidden after the request ran are hidden, and for a request that names one, those hidden before it too. A reply with no path separator in it is not searched. Encoded file data (dataBase64, pngBase64) is sent unchanged. Recorded steps in ui.inspect leave out a path outside the read roots under path, paths, file, files, folder and any param whose name ends in Path, Paths, File, Files or Folder (diffPath, fontFiles, destinationFolder), and an absolute path or file: URL outside the roots under any other param or in any list. The file: scheme is matched in any case, a URL's %XX escapes are decoded before the check, a file: URL inside the roots stays, and a file: URL that names another host is always left out. file_access::is_path_key holds the list of path params, and testkit's is_path_key uses it. --- crates/engine/src/file_access.rs | 7 + crates/testkit/src/strategies.rs | 12 +- crates/ui-egui/src/control.rs | 273 +++++++++++++++++++++---- crates/ui-egui/src/control/tests.rs | 57 ++++++ crates/ui-egui/src/tests/automation.rs | 113 ++++++++-- docs/mcp.md | 16 +- 6 files changed, 410 insertions(+), 68 deletions(-) diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 73c8ba8efe..9d05b17272 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -215,6 +215,13 @@ pub fn check_read(path: &str) -> Result<(), String> { current().map_or(Ok(()), |r| r.check(path, Access::Read)) } +/// Does a command param named `key` hold a file or folder path: `path`, `paths`, `file`, `files`, +/// `folder`, or a name ending in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`, +/// `destinationFolder`)? +pub fn is_path_key(key: &str) -> bool { + matches!(key, "path" | "paths" | "file" | "files" | "folder") || ["Path", "Paths", "File", "Files", "Folder"].iter().any(|end| key.ends_with(end)) +} + /// May `path` be written here? Always, without roots in force. pub fn check_write(path: &str) -> Result<(), String> { current().map_or(Ok(()), |r| r.check(path, Access::Write))?; diff --git a/crates/testkit/src/strategies.rs b/crates/testkit/src/strategies.rs index 10b0608001..372152870d 100644 --- a/crates/testkit/src/strategies.rs +++ b/crates/testkit/src/strategies.rs @@ -502,15 +502,17 @@ pub fn param_keys(doc: &str) -> Vec<String> { out } -/// Parameter-name keys that name a file or folder a command may write: fuzzing these with -/// arbitrary strings could write files anywhere, so callers substitute a temp path. +/// Parameter-name keys that name a file or folder a command may write +/// ([`vectorcraft_engine::file_access::is_path_key`]): fuzzing these with arbitrary strings could +/// write files anywhere, so callers substitute a temp path. pub fn is_path_key(k: &str) -> bool { - matches!(k, "path" | "diffPath") || is_folder_key(k) + vectorcraft_engine::file_access::is_path_key(k) } -/// Path keys that name a folder a command creates or writes files into. +/// Path keys that name a folder a command creates or writes files into: `folder` or a name ending in +/// `Folder` (`destinationFolder`). pub fn is_folder_key(k: &str) -> bool { - matches!(k, "folder" | "destinationFolder") + k == "folder" || k.ends_with("Folder") } /// The location to send under the path key `k`: `safe_path` for a file, a folder next to it for diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d0be1a13ef..c38baf5165 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -308,23 +308,10 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { // for the person at the computer: what asked for it runs again when it answers. "fileDialog": app.picks.waiting_kind(), }); - let ids = unreadable_library_ids(app); - for field in PANEL_FIELDS.iter().chain(&LIBRARY_ID_FIELDS) { - if let Some(v) = inspected["ui"].get_mut(*field) { - hide_library_ids(v, &ids); - } - } - let pairs: Vec<_> = ids.iter().map(|(id, shown)| (id.as_str(), shown.as_str())).collect(); - if let Some(sets) = inspected["ui"].get_mut("action_sets") { - swap_library_params(sets, &pairs); - } + hide_library_ids(&mut inspected, &unreadable_library_ids(app)); inspected } -/// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold library ids or panel -/// ids besides [`PANEL_FIELDS`]: the library panels, the Color Guide's limit and the workspaces. -const LIBRARY_ID_FIELDS: [&str; 4] = ["library_panels", "color_guide_limit", "custom_workspaces", "workspace_changes"]; - /// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold panel ids: the panel /// popped out of the collapsed column, the docking layout, closed panels' places and groups, the panels /// shown as icons or collapsed, and the older floating panels' record. @@ -336,8 +323,8 @@ const PANEL_FIELDS: [&str; 7] = /// loaded without a path is named. The UI state refers to a library by its bare id in a library panel /// and the Color Guide's limit, by a panel id (`library:<kind>:loaded/<path>`) in the fields of /// [`PANEL_FIELDS`] and the workspaces' layouts, and by either in the `library` and `panel` params of -/// recorded actions' steps. Text the person types (a command palette query, a workspace's or an -/// action's name) is not read. The ids are numbered with the session's loaded libraries as the +/// recorded actions' steps; the session lists the libraries it has loaded. Text the person types (a +/// command palette query, a workspace's or an action's name) is not read. The ids are numbered as the /// library commands number them ([`vectorcraft_engine::cmd::swatchlib::concealed_library_ids`]), so /// that two libraries stay two and `ui.inspect`, `ui.docking` and the library commands show a library /// under the same id. @@ -354,11 +341,16 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { } } collect_library_panel_ids(&json!(app.ui.transient_panel), &mut ids); - for id in app.ui.library_panels.iter().map(|p| p.id.as_str()).chain([app.ui.color_guide_limit.as_str()]) { + // The ids of the libraries loaded in the session: a hidden one does not take the id of one loaded + // without a path. + let s = &app.session; + let loaded = s.swatch_libraries.infos().chain(s.style_libraries.infos()).chain(s.symbol_libraries.infos()).chain(s.brush_libraries.infos()); + let loaded: Vec<&str> = loaded.map(|info| info.id.as_str()).collect(); + for id in app.ui.library_panels.iter().map(|p| p.id.as_str()).chain([app.ui.color_guide_limit.as_str()]).chain(loaded.iter().copied()) { add_library_id(id, &mut ids); } let mut hidden = vectorcraft_engine::cmd::swatchlib::concealed_library_ids(&app.session, ids.iter().cloned()); - // Only the ids the UI state refers to are replaced in the reply. + // Only the ids the UI state and the session refer to are replaced in the reply. hidden.retain(|(id, _)| ids.contains(id)); // The longest first, so that an id that begins another one does not replace part of it. hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); @@ -393,11 +385,106 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet<Str } } -/// Writes each id of `ids` as the id the reply shows instead, wherever `v` holds it: in a library's -/// id, in its panel's id (`library:swatches:loaded/<path>`) and in keys. +/// Writes each id of `ids` as the id the reply shows instead, and the path in it as the file name +/// shown, wherever `v` holds them, in every string and key: a library's id, its panel's id +/// (`library:swatches:loaded/<path>`), status text, a dialog, the command palette query, a script's +/// report and a recorded step. An occurrence followed by a character that continues a path +/// ([`continues_path`]) stays only when the request may read the longer path +/// ([`readable_continuation`]): a readable library's id that begins with a hidden one keeps its path. fn hide_library_ids(v: &mut Value, ids: &[(String, String)]) { - let pairs: Vec<_> = ids.iter().map(|(id, shown)| (id.as_str(), shown.as_str())).collect(); - swap_library_ids(v, &pairs); + replace_in_value(v, &hidden_text(ids)); +} + +/// The text [`hide_library_ids`] replaces, longest first: each hidden id with the id shown, and the +/// path in it with the file name shown, the path written with either separator too, and with each +/// `\` doubled (a path quoted with `{:?}`). +fn hidden_text(ids: &[(String, String)]) -> Vec<(String, String)> { + let mut pairs: Vec<(String, String)> = Vec::new(); + for (id, shown) in ids { + let path = &id["loaded/".len()..]; + let name = shown.strip_prefix("loaded/").unwrap_or(shown); + let back = path.replace('/', "\\"); + for path in [path.to_string(), path.replace('\\', "/"), back.replace('\\', "\\\\"), back] { + for (from, to) in [(format!("loaded/{path}"), shown.clone()), (path, name.to_string())] { + if !pairs.iter().any(|(f, _)| *f == from) { + pairs.push((from, to)); + } + } + } + } + pairs.sort_by_key(|(from, _)| std::cmp::Reverse(from.len())); + pairs +} + +/// Writes each `from` text of `pairs` as its `to` text in every string and key of `v`, at each +/// occurrence not followed by a character that continues a path ([`continues_path`]) into a path the +/// request may read ([`readable_continuation`]). Encoded file data, under a key ending in `Base64` +/// (`dataBase64`, `pngBase64`), stays as it is. +fn replace_in_value(v: &mut Value, pairs: &[(String, String)]) { + if pairs.is_empty() { + return; + } + match v { + Value::String(s) => { + if let Some(replaced) = replace_text(s, pairs) { + *s = replaced; + } + } + Value::Array(items) => items.iter_mut().for_each(|item| replace_in_value(item, pairs)), + Value::Object(fields) => { + for (key, mut item) in std::mem::take(fields) { + if key.ends_with("Base64") { + fields.insert(key, item); + continue; + } + replace_in_value(&mut item, pairs); + fields.insert(replace_text(&key, pairs).unwrap_or(key), item); + } + } + _ => {} + } +} + +/// `s` with the `from` texts of `pairs` written as their `to` texts, as [`replace_in_value`] writes +/// them. `None` when nothing changed. +fn replace_text(s: &str, pairs: &[(String, String)]) -> Option<String> { + let mut out: Option<String> = None; + for (from, to) in pairs { + let text = out.as_deref().unwrap_or(s); + if from.is_empty() || !text.contains(from.as_str()) { + continue; + } + let mut next = String::with_capacity(text.len()); + let mut copied = 0; + for (at, _) in text.match_indices(from.as_str()) { + let end = at + from.len(); + if !continues_path(&text[end..]) || !readable_continuation(&text[at..]) { + next.push_str(&text[copied..at]); + next.push_str(to); + copied = end; + } + } + if copied > 0 { + next.push_str(&text[copied..]); + out = Some(next); + } + } + out +} + +/// Does text that follows a path continue it: a letter, a digit, `_` or a separator, after any `.`, +/// `-` or `~`? A space, punctuation, a quote, a sentence's final period and the end of the text end it. +fn continues_path(rest: &str) -> bool { + rest.trim_start_matches(['.', '-', '~']).starts_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '/' | '\\')) +} + +/// May the request read the longer path that `text` starts with, up to a space, a quote, a bracket, +/// `,`, `;` or `|`, without a `loaded/` before it? A readable library's id that begins with a hidden +/// one stays; a hidden path followed by more path text (`<path>.bak`) is still hidden. +fn readable_continuation(text: &str) -> bool { + let end = text.find(|c: char| c.is_whitespace() || "\"'`()[]{}<>,;|".contains(c)).unwrap_or(text.len()); + let path = &text[..end]; + readable(path.strip_prefix("loaded/").unwrap_or(path)) } /// Text that a person or a script wrote, by its key: a command palette query, a script's report and a @@ -484,10 +571,69 @@ fn readable(path: &str) -> bool { vectorcraft_engine::file_access::check_read(path).is_ok() } -/// Does a recorded step's param `key` hold a file or folder path: `path`, `folder` or a name ending in -/// `Folder` (`destinationFolder`, `searchFolder`)? -fn is_path_param(key: &str) -> bool { - matches!(key, "path" | "folder") || key.ends_with("Folder") +/// Is `s` a path the request may not read, written as an absolute path (either separator, with or +/// without a drive) or a `file:` URL ([`file_url_path`])? A `file:` URL that names another host is +/// never readable. +fn unreadable_absolute(s: &str) -> bool { + match file_url_path(s) { + Some(path) => !path.is_some_and(|p| readable(&p)), + None => { + let absolute = s.starts_with(['/', '\\']) || matches!(s.as_bytes(), [drive, b':', b'/' | b'\\', ..] if drive.is_ascii_alphabetic()); + absolute && !readable(s) + } + } +} + +/// May the request read the path `s` names, a path or a `file:` URL ([`file_url_path`])? +fn readable_value(s: &str) -> bool { + match file_url_path(s) { + Some(path) => path.is_some_and(|p| readable(&p)), + None => readable(s), + } +} + +/// The local path that `s` names when it is a `file:` URL, with the scheme in any case and its `%XX` +/// escapes decoded: `Some(None)` for a URL that names another host (`file://host/share`). +/// `file:///C:/x` names `C:/x`. +fn file_url_path(s: &str) -> Option<Option<String>> { + let rest = s.get(..5).filter(|scheme| scheme.eq_ignore_ascii_case("file:")).map(|_| &s[5..])?; + let Some(rest) = rest.strip_prefix("//") else { return Some(Some(percent_decoded(rest))) }; + let rest = if rest.get(..9).is_some_and(|host| host.eq_ignore_ascii_case("localhost")) { &rest[9..] } else { rest }; + if !rest.starts_with('/') { + return Some(None); + } + let path = match rest.as_bytes() { + [b'/', drive, b':', ..] if drive.is_ascii_alphabetic() => &rest[1..], + _ => rest, + }; + Some(Some(percent_decoded(path))) +} + +/// `s` with `%XX` escapes decoded, as the SVG importer decodes an href. +fn percent_decoded(s: &str) -> String { + if !s.contains('%') { + return s.to_string(); + } + let mut out = Vec::with_capacity(s.len()); + let mut rest = s.as_bytes(); + while let Some((&b, tail)) = rest.split_first() { + let hex = tail + .get(..2) + .filter(|h| h.iter().all(u8::is_ascii_hexdigit)) + .and_then(|h| std::str::from_utf8(h).ok()) + .and_then(|h| u8::from_str_radix(h, 16).ok()); + match hex { + Some(v) if b == b'%' => { + out.push(v); + rest = tail.get(2..).unwrap_or_default(); + } + _ => { + out.push(b); + rest = tail; + } + } + } + String::from_utf8_lossy(&out).into_owned() } /// The params of each step of each action of action set `set` (`ui.action_sets`). @@ -498,22 +644,35 @@ fn steps_mut(set: &mut Value) -> impl Iterator<Item = &mut serde_json::Map<Strin } /// Takes out of a recorded step's params, at any depth (the commands of a `command.batch` step), the -/// paths the request may not read: a path param ([`is_path_param`]) and each path of a `paths` list. +/// paths the request may not read: the value of a path param +/// ([`vectorcraft_engine::file_access::is_path_key`]: `path`, `file`, `diffPath`, `destinationFolder` +/// and the like), each path of a list under such a param, and under any other param an absolute path +/// or a `file:` URL ([`unreadable_absolute`]). fn leave_out_unreadable_params(params: &mut serde_json::Map<String, Value>) { - params.retain(|key, v| !(is_path_param(key) && v.as_str().is_some_and(|p| !readable(p)))); - if let Some(Value::Array(paths)) = params.get_mut("paths") { - paths.retain(|p| p.as_str().is_none_or(readable)); - } - for v in params.values_mut() { + use vectorcraft_engine::file_access::is_path_key; + params.retain(|key, v| match v.as_str() { + Some(p) => !unreadable_absolute(p) && !(is_path_key(key) && !readable_value(p)), + None => true, + }); + for (key, v) in params.iter_mut() { + if let Value::Array(paths) = v + && is_path_key(key) + { + paths.retain(|p| p.as_str().is_none_or(readable_value)); + } leave_out_unreadable_values(v); } } -/// [`leave_out_unreadable_params`] on each object `v` holds. +/// [`leave_out_unreadable_params`] on each object `v` holds, and an absolute path or a `file:` URL +/// the request may not read taken out of each list. fn leave_out_unreadable_values(v: &mut Value) { match v { Value::Object(fields) => leave_out_unreadable_params(fields), - Value::Array(items) => items.iter_mut().for_each(leave_out_unreadable_values), + Value::Array(items) => { + items.retain(|item| !item.as_str().is_some_and(unreadable_absolute)); + items.iter_mut().for_each(leave_out_unreadable_values); + } _ => {} } } @@ -609,21 +768,45 @@ const DOCKING_ACTION: &str = "action"; /// Runs a control request. A library loaded from outside the read root keeps the id `ui.inspect` /// shows for it ([`unreadable_library_ids`]) both ways: a request may name it or its panel by that id -/// in a [`LIBRARY_PARAMS`] param, and the reply to such a request shows that id. +/// in a [`LIBRARY_PARAMS`] param, and the reply shows that id. Under a read root, the reply shows each +/// such library's id and path as `ui.inspect` does ([`hide_library_ids`]) in every string and key, +/// errors included: the libraries hidden after it ran, and before it for a request that names one. +/// A reply with no `/` or `\` in it is not searched, since every hidden path holds a separator. pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { - let ids = if names_a_loaded_library(&req.params) { unreadable_library_ids(app) } else { Vec::new() }; - let shown: Vec<_> = ids.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect(); - let mut params = req.params.clone(); - if shown.is_empty() || !swap_library_params(&mut params, &shown) { + if vectorcraft_engine::file_access::current().is_none() { return dispatch(app, ctx, req); } - let named = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq }; - match dispatch(app, ctx, &named) { - Outcome::Done(mut reply) => { - hide_library_ids(&mut reply, &ids); - Outcome::Done(reply) + let before = if names_a_loaded_library(&req.params) { unreadable_library_ids(app) } else { Vec::new() }; + let shown: Vec<_> = before.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect(); + let mut params = req.params.clone(); + let outcome = if !shown.is_empty() && swap_library_params(&mut params, &shown) { + let request = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq }; + dispatch(app, ctx, &request) + } else { + dispatch(app, ctx, req) + }; + let Outcome::Done(mut reply) = outcome else { return outcome }; + if !holds_a_separator(&reply) { + return Outcome::Done(reply); + } + let mut ids = unreadable_library_ids(app); + for pair in before { + if !ids.iter().any(|(id, _)| *id == pair.0) { + ids.push(pair); } - outcome => outcome, + } + hide_library_ids(&mut reply, &ids); + Outcome::Done(reply) +} + +/// Does a string or a key of `v`, at any depth, hold a `/` or a `\`? +fn holds_a_separator(v: &Value) -> bool { + let has = |s: &str| s.contains(['/', '\\']); + match v { + Value::String(s) => has(s), + Value::Array(items) => items.iter().any(holds_a_separator), + Value::Object(fields) => fields.iter().any(|(key, item)| has(key) || holds_a_separator(item)), + _ => false, } } diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 1763fd679a..bc1dab6c42 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -12,6 +12,63 @@ fn input_request(app: &mut VectorcraftApp, method: &str, params: Value) -> Value r } +/// A reply hides a hidden library's id and path at every occurrence, in an error that wraps them in +/// spaces, brackets or quotes, follows them with more path text, or quotes the path with `{:?}`. +#[test] +fn a_reply_hides_a_hidden_library_whatever_surrounds_it() { + let path = std::env::temp_dir().join("vc-named-reply").join("b.svg").to_string_lossy().into_owned(); + let id = format!("loaded/{path}"); + let hidden = [(id.clone(), "loaded/b.svg".to_string())]; + let mut reply = json!({"ok": false, "error": format!("can't add ( {id} ) or '{id}', from {path}; also {id}.bak and {path:?}")}); + let root = std::env::temp_dir().join(format!("vc-named-reply-root-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden)); + let _ = std::fs::remove_dir_all(root); + assert_eq!(reply["error"], "can't add ( loaded/b.svg ) or 'loaded/b.svg', from b.svg; also loaded/b.svg.bak and \"b.svg\"", "{reply}"); +} + +/// Encoded file data in a reply (`dataBase64`) stays as it is when it holds a hidden library's path. +#[test] +fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() { + let hidden = [("loaded/assets/icon".to_string(), "loaded/icon".to_string())]; + let data = "QUJDassets/iconREVG"; + let mut reply = json!({"ok": true, "result": {"files": [{"name": "a.png", "dataBase64": data}], "status": "from assets/icon"}}); + let root = std::env::temp_dir().join(format!("vc-base64-root-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden)); + let _ = std::fs::remove_dir_all(root); + assert_eq!(reply["result"]["files"][0]["dataBase64"], data, "{reply}"); + assert_eq!(reply["result"]["status"], "from icon", "{reply}"); +} + +/// Under a read root, an absolute path or a `file:` URL outside it is unreadable; an empty string, a +/// relative name and a path inside the root are not. +#[test] +fn an_absolute_path_outside_the_read_root_is_unreadable() { + let base = std::env::temp_dir().join(format!("vc-unreadable-absolute-{}", std::process::id())); + std::fs::create_dir_all(base.join("in")).unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&base.join("in")], &[]).unwrap(); + let inside = base.join("in").join("a.svg").to_string_lossy().into_owned(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || { + assert!(super::unreadable_absolute("/outside/root/x")); + assert!(super::unreadable_absolute("file:///outside/root/x")); + assert!(super::unreadable_absolute("FILE:///outside/root/x")); + assert!(super::unreadable_absolute("file://other-host/share/x")); + let url = if inside.starts_with('/') { format!("File://{inside}") } else { format!("File:///{}", inside.replace('\\', "/")) }; + assert!(!super::unreadable_absolute(&url), "{url}"); + assert!(super::readable_value(&url), "{url}"); + let escaped = url.replacen("/in/a.svg", "/%69n/a.svg", 1); + assert!(!super::unreadable_absolute(&escaped) && super::readable_value(&escaped), "{escaped}"); + assert!(super::unreadable_absolute("file:///outside%2Froot/x")); + assert!(!super::unreadable_absolute("")); + assert!(!super::unreadable_absolute("keep")); + assert!(!super::unreadable_absolute(&inside)); + }); + let _ = std::fs::remove_dir_all(base); +} + #[test] fn deferred_pointer_reply_uses_the_gesture_document_after_file_new() { let mut app = VectorcraftApp::new(Session::new(), Services::default()); diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 8f6e0f5ab5..904068cfc8 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -487,22 +487,106 @@ fn ui_inspect_checks_a_folder_preference_as_written() { let _ = std::fs::remove_dir_all(base); } -/// `ui.inspect` rewrites a hidden library's id only in the fields that hold library and panel ids: -/// status text that is that id stays as written. +/// `ui.inspect` hides a hidden library's id and path in every string it holds: status text, the +/// command palette query and a script's report show the id and file name shown, and the path appears +/// nowhere in the reply. #[test] -fn ui_inspect_keeps_status_text_that_is_a_hidden_library_id() { +fn ui_inspect_hides_a_hidden_library_path_in_every_string() { let (mut app, tx, base) = confined("inspect-status-id"); - let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + let path = s(base.join("outside/b.svg")); + let theirs = format!("loaded/{path}"); app.ui.color_guide_limit = theirs.clone(); - app.ui.status = theirs.clone(); + app.ui.status = format!("Loaded {path} (3 swatches), kept {path}.bak"); + app.ui.palette_query = format!("\"{theirs}\""); + let source = format!("vc.log({})", json!(format!("read {path}."))); + app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); + for _ in 0..10_000 { + if app.session.script_pump().is_some() { + break; + } + } let r = request(&mut app, &tx, "ui.inspect", json!({})); assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}"); - assert_eq!(r["result"]["ui"]["status"], json!(theirs), "{r}"); + assert_eq!(r["result"]["ui"]["status"], "Loaded b.svg (3 swatches), kept b.svg.bak", "{r}"); + assert_eq!(r["result"]["ui"]["palette_query"], "\"loaded/b.svg\"", "{r}"); + assert_eq!(r["result"]["scriptReport"]["log"], json!(["read b.svg."]), "{r}"); + let outside = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// The reply to a request that names a hidden library by the id `ui.inspect` shows hides that +/// library's id wherever the reply holds it, also between spaces and brackets and where a character +/// that continues a path follows it. +#[test] +fn a_reply_hides_a_library_the_request_named_by_its_shown_id_everywhere() { + let (mut app, tx, base) = confined("named-hidden-reply"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + app.ui.color_guide_limit = theirs.clone(); + app.ui.status = format!("Not added ( {theirs} ); kept {theirs}_old"); + let r = request(&mut app, &tx, "ui.inspect", json!({"library": "loaded/b.svg"})); + assert_eq!(r["result"]["ui"]["status"], "Not added ( loaded/b.svg ); kept loaded/b.svg_old", "{r}"); + let outside = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// An error to a request that names a hidden library by the id `ui.inspect` shows names it by that id, +/// and its path appears nowhere in the reply. +#[test] +fn an_error_for_a_hidden_library_named_by_its_shown_id_shows_that_id() { + let (mut app, tx, base) = confined("named-hidden-error"); + app.ui.color_guide_limit = format!("loaded/{}", s(base.join("outside/b.svg"))); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "ui.colorGuideLimit", "params": {"library": "loaded/b.svg"}})); + assert_eq!(r["ok"], false, "{r}"); + assert!(r["error"].as_str().is_some_and(|e| e.contains("loaded/b.svg")), "{r}"); + let outside = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` leaves out of a recorded action's steps a path outside the read root under any path +/// param (`diffPath`, `file`, a name ending in `Files`), and an absolute path or a `file:` URL outside +/// it (the scheme in any case) under any other param or in any list. A `file:` URL inside it stays. +#[test] +fn ui_inspect_leaves_out_recorded_paths_under_any_key() { + let (mut app, tx, base) = confined("inspect-action-keys"); + let (a, b) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg"))); + let to_url = |p: &str| if p.starts_with('/') { format!("file://{p}") } else { format!("file:///{}", p.replace('\\', "/")) }; + let (url, url_in) = (to_url(&b), to_url(&a)); + let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ + "name": "set", + "actions": [{"name": "files", "steps": [ + ["document.compare", {"diffPath": b, "target": a}], + ["file.open", {"file": b}], + ["type.fonts", {"fontFiles": [a, b, url_in]}], + ["x.y", {"target": b, "url": url, "upper": url.replacen("file:", "FILE:", 1), "other": "keep"}], + ["x.z", {"list": [b, "keep"]}], + ["x.w", {"share": "file://other-host/share/b.svg", "other": "keep"}], + ["x.v", {"file": url_in}], + ]}], + })) + .unwrap(); + app.ui.action_sets = vec![set]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"]; + let want = json!([ + ["document.compare", {"target": a}], + ["file.open", {}], + ["type.fonts", {"fontFiles": [a, url_in]}], + ["x.y", {"other": "keep"}], + ["x.z", {"list": ["keep"]}], + ["x.w", {"other": "keep"}], + ["x.v", {"file": url_in}], + ]); + assert_eq!(steps, &want, "{r}"); + let outside = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); let _ = std::fs::remove_dir_all(base); } /// `ui.inspect` hides a library loaded from outside the read root in the steps of a recorded action -/// (its `library` and `panel` params), also when nothing else names it, and keeps the action's name. +/// (its `library` and `panel` params) and in the action's name, also when nothing else names it. #[test] fn ui_inspect_hides_a_hidden_library_in_recorded_steps() { let (mut app, tx, base) = confined("inspect-action-steps"); @@ -518,7 +602,7 @@ fn ui_inspect_hides_a_hidden_library_in_recorded_steps() { app.ui.action_sets = vec![set]; let r = request(&mut app, &tx, "ui.inspect", json!({})); let action = &r["result"]["ui"]["action_sets"][0]["actions"][0]; - assert_eq!(action["name"], json!(theirs), "{r}"); + assert_eq!(action["name"], "loaded/b.svg", "{r}"); assert_eq!( action["steps"], json!([["swatch.library.add", {"library": "loaded/b.svg"}], ["window.panel", {"panel": "library:swatches:loaded/b.svg"}]]), @@ -731,7 +815,7 @@ fn ui_inspect_script_report_leaves_out_files_outside_the_read_root() { /// `ui.inspect` rewrites a hidden library's id only where a string is that whole id: a readable /// library's id that begins with it stays, and a script's report line and a command palette query -/// that hold it stay as written. +/// that end with it show the id shown. #[test] fn ui_inspect_rewrites_only_whole_hidden_library_ids() { let (mut app, tx, base) = confined("inspect-whole-id"); @@ -740,8 +824,7 @@ fn ui_inspect_rewrites_only_whole_hidden_library_ids() { assert!(readable.starts_with(&hidden)); let panel = |id: &str| -> crate::panels::library_panel::LibraryPanel { serde_json::from_value(json!({"kind": "swatches", "id": id})).unwrap() }; app.ui.library_panels = vec![panel(&hidden), panel(&readable)]; - let query = format!("library:swatches:{hidden}"); - app.ui.palette_query = query.clone(); + app.ui.palette_query = format!("library:swatches:{hidden}"); let text = format!("found {hidden}"); let source = format!("vc.log({})", json!(text)); app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); @@ -753,8 +836,12 @@ fn ui_inspect_rewrites_only_whole_hidden_library_ids() { let r = request(&mut app, &tx, "ui.inspect", json!({})); let ids: Vec<_> = r["result"]["ui"]["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect(); assert_eq!(ids, [json!("loaded/i"), json!(readable)], "{r}"); - assert_eq!(r["result"]["scriptReport"]["log"], json!([text]), "{r}"); - assert_eq!(r["result"]["ui"]["palette_query"], json!(query), "{r}"); + assert_eq!(r["result"]["scriptReport"]["log"], json!(["found loaded/i"]), "{r}"); + assert_eq!(r["result"]["ui"]["palette_query"], "library:swatches:loaded/i", "{r}"); + // A request that names the hidden library by the id shown keeps the readable id too. + let r = request(&mut app, &tx, "ui.inspect", json!({"library": "loaded/i"})); + let ids: Vec<_> = r["result"]["ui"]["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect(); + assert_eq!(ids, [json!("loaded/i"), json!(readable)], "{r}"); let _ = std::fs::remove_dir_all(base); } diff --git a/docs/mcp.md b/docs/mcp.md index ac035e7cfc..a4e322818f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -143,8 +143,11 @@ the suite) keep the agent to the folders you give it: `file.openRecentN` for a file outside the roots ends with `no such recent file`, as an empty slot does. - `ui_inspect` (the control channel's `ui.inspect`) leaves out the paths outside the read roots: in `opening`, the documents still being read; in `ui.recent_files`, the recent files; a library panel's `path` in `ui.library_panels`; - in a recorded action's steps in `ui.action_sets`, also in a batch step's commands, the `path` and `folder` params, - a param whose name ends in `Folder` (`destinationFolder`) and the entries of a `paths` param; and the folder + in a recorded action's steps in `ui.action_sets`, also in a batch step's commands, the `path`, `paths`, `file`, + `files` and `folder` params, a param whose name ends in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`, + `destinationFolder`), the entries of a list under such a param, and under any other param or in any list an + absolute path or a `file:` URL outside the roots (any case of `file:`, `%XX` escapes decoded; a URL that names + another host is always left out); and the folder preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`; an empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the @@ -159,9 +162,12 @@ the suite) keep the agent to the folders you give it: changed. A request may name such a library or its panel by the id shown in a `panel`, `library` or `panels` param, in a docking destination (`anchor`, `before`, `onto`, `above`, `below`) and in `window.panel.layout`'s `action` (`ui.set {panel: "library:swatches:loaded/<file name>"}` - reopens its panel), and the reply to such a request, an error included, shows that id too. `ui_inspect` rewrites only whole ids, and only in the fields that hold library and panel ids: - a longer id that begins with a hidden one, the status text (`ui.status`), the command palette query - (`ui.palette_query`) and a script's report (`scriptReport`) keep their text. + reopens its panel), and the reply to such a request shows that id and file name wherever the library's id or path + appears, an error included. Every reply under the read roots shows a hidden library's id as the id shown and its + path as the file name in every string and key, the status text (`ui.status`), dialogs, the command palette query + (`ui.palette_query`) and a script's report (`scriptReport`) included. A longer path that begins with a hidden one + keeps its text only when it lies inside the read roots, so a readable library's id that begins with a hidden one + stays. Encoded file data (`dataBase64`, `pngBase64`) is sent as it is. - `swatch.library.list`, `brush.libraries`, `symbol.libraries` and `graphicStyle.libraries` list every library but leave out the paths outside the read roots: a library's `path` and the user library folder in `userFolder` are `null` there. A library loaded from a file outside the roots is listed as `loaded/<file name>`, numbered From 00f5a16494832948ad73c445cec6988846c5c1b2 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 16:33:37 -0800 Subject: [PATCH 29/92] MCP: replies hide library paths outside the read roots in more forms - Every request under a read root records the hidden libraries before it runs, and the reply hides those and the ones hidden after it. A panic is answered inside the request, so its error is hidden too. - A hidden path followed by more path text stays only when the longer text is another loaded library's id with no `.` or `..` part. - Encoded file data is skipped only for a base64 string under `dataBase64`, `pngBase64` or `thumbnailBase64`; objects and lists under those keys, and other keys ending in `Base64`, are searched. - The error of a failed request hides a path outside the read roots that the request sent by its file name, so it reads the same whether or not a library was loaded from that path. - Paths written with `%XX` escapes are hidden, and on Windows paths in another letter case. - Recorded steps in `ui.inspect` leave out `~`, paths under `~` and paths starting with `./` or `../` outside the read roots under any param. - Hidden libraries are numbered in load order and keep their id from one request to the next. --- crates/ui-egui/src/control.rs | 417 ++++++++++++++++++++----- crates/ui-egui/src/control/tests.rs | 126 +++++++- crates/ui-egui/src/tests/automation.rs | 114 ++++++- docs/mcp.md | 18 +- 4 files changed, 575 insertions(+), 100 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c38baf5165..3fba434993 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -323,38 +323,115 @@ const PANEL_FIELDS: [&str; 7] = /// loaded without a path is named. The UI state refers to a library by its bare id in a library panel /// and the Color Guide's limit, by a panel id (`library:<kind>:loaded/<path>`) in the fields of /// [`PANEL_FIELDS`] and the workspaces' layouts, and by either in the `library` and `panel` params of -/// recorded actions' steps; the session lists the libraries it has loaded. Text the person types (a -/// command palette query, a workspace's or an action's name) is not read. The ids are numbered as the -/// library commands number them ([`vectorcraft_engine::cmd::swatchlib::concealed_library_ids`]), so -/// that two libraries stay two and `ui.inspect`, `ui.docking` and the library commands show a library -/// under the same id. -fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { - let mut ids = std::collections::BTreeSet::new(); +/// recorded actions' steps. Text the person types (a command palette query, a workspace's or an +/// action's name) is not read. The session's loaded libraries whose file the request may not read are +/// hidden too. The ids are numbered as the library commands number them +/// ([`vectorcraft_engine::cmd::swatchlib::concealed_library_ids`]), so that two libraries stay two and +/// `ui.inspect`, `ui.docking` and the library commands show a library under the same id. +fn unreadable_library_ids(app: &VectorcraftApp) -> Hiding { + let mut rest = std::collections::BTreeSet::new(); let ui = serde_json::to_value(&app.ui).unwrap_or_default(); for field in PANEL_FIELDS { - collect_library_panel_ids(&ui[field], &mut ids); + collect_library_panel_ids(&ui[field], &mut rest); } - collect_library_params(&ui["action_sets"], &mut ids); + collect_library_params(&ui["action_sets"], &mut rest); for workspace in ["custom_workspaces", "workspace_changes"].iter().filter_map(|k| ui[k].as_array()).flatten() { for (_, layout) in workspace.as_object().into_iter().flatten().filter(|(k, _)| k.as_str() != "name") { - collect_library_panel_ids(layout, &mut ids); + collect_library_panel_ids(layout, &mut rest); } } - collect_library_panel_ids(&json!(app.ui.transient_panel), &mut ids); - // The ids of the libraries loaded in the session: a hidden one does not take the id of one loaded - // without a path. - let s = &app.session; - let loaded = s.swatch_libraries.infos().chain(s.style_libraries.infos()).chain(s.symbol_libraries.infos()).chain(s.brush_libraries.infos()); - let loaded: Vec<&str> = loaded.map(|info| info.id.as_str()).collect(); - for id in app.ui.library_panels.iter().map(|p| p.id.as_str()).chain([app.ui.color_guide_limit.as_str()]).chain(loaded.iter().copied()) { + collect_library_panel_ids(&json!(app.ui.transient_panel), &mut rest); + let mut ids = rest; + for id in app.ui.library_panels.iter().map(|p| p.id.as_str()).chain([app.ui.color_guide_limit.as_str()]) { add_library_id(id, &mut ids); } let mut hidden = vectorcraft_engine::cmd::swatchlib::concealed_library_ids(&app.session, ids.iter().cloned()); - // Only the ids the UI state and the session refer to are replaced in the reply. - hidden.retain(|(id, _)| ids.contains(id)); + // The other loaded library ids the session and the UI state hold. + let s = &app.session; + let loaded = s.swatch_libraries.infos().chain(s.style_libraries.infos()).chain(s.symbol_libraries.infos()).chain(s.brush_libraries.infos()); + let readable_ids = loaded + .map(|info| info.id.clone()) + .chain(ids) + .filter(|id| id.len() > "loaded/".len() && id.starts_with("loaded/") && !hidden.iter().any(|(h, _)| h == id)) + .collect(); // The longest first, so that an id that begins another one does not replace part of it. hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); - hidden + Hiding { hidden, readable: readable_ids } +} + +/// What a reply hides ([`hide_library_ids`]). +#[derive(Debug, Default)] +struct Hiding { + /// The ids of the loaded libraries the request may not read, longest first, each with the id + /// shown instead ([`unreadable_library_ids`]). + hidden: Vec<(String, String)>, + /// The other loaded library ids the UI state and the session hold: an occurrence of a hidden id or + /// path that continues into one of them stays ([`known_continuation`]). + readable: std::collections::BTreeSet<String>, +} + +impl Hiding { + /// Adds the hidden and readable ids of `other` that this one does not hold. + fn merge(&mut self, other: Hiding) { + self.add_hidden(other.hidden); + self.readable.extend(other.readable); + } + + /// Adds the hidden ids of `pairs` that this one does not hold, each with the id shown. + fn add_hidden(&mut self, pairs: Vec<(String, String)>) { + for pair in pairs { + if !self.hidden.iter().any(|(id, _)| *id == pair.0) { + self.hidden.push(pair); + } + } + self.hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); + } + + /// Adds the hidden ids of `pairs`, each with the id shown, in place of each id this one holds for + /// the same path: written with either separator, and in any case of ASCII letters with `fold`. + fn set_hidden(&mut self, pairs: Vec<(String, String)>, fold: bool) { + let key = |id: &str| { + let id = id.replace('\\', "/"); + if fold { id.to_ascii_lowercase() } else { id } + }; + let sent: Vec<String> = pairs.iter().map(|(id, _)| key(id)).collect(); + self.hidden.retain(|(id, _)| !sent.contains(&key(id))); + self.add_hidden(pairs); + } +} + +/// The paths outside the read roots that `v`'s strings and keys name at any depth, written as paths +/// ([`unreadable_path_like`]), each as a hidden library id (`loaded/<path>`) with `loaded/<file name>` +/// shown. The error of a failed request hides them as it hides a hidden library's path, with that file +/// name also for a hidden library shown as `loaded/<file name> (2)`, so that it reads the same whether +/// or not a library was loaded from one of them. +fn sent_paths(v: &Value, out: &mut Vec<(String, String)>) { + fn add(s: &str, out: &mut Vec<(String, String)>) { + if !unreadable_path_like(s) { + return; + } + let path = match file_url_path(s) { + Some(Some(path)) => path, + Some(None) => return, + None => s.to_string(), + }; + let Some(name) = path.rsplit(['/', '\\']).find(|part| !part.is_empty()).filter(|name| *name != path) else { return }; + let pair = (format!("loaded/{path}"), format!("loaded/{name}")); + if !out.contains(&pair) { + out.push(pair); + } + } + match v { + Value::String(s) => add(s, out), + Value::Array(items) => items.iter().for_each(|item| sent_paths(item, out)), + Value::Object(fields) => { + for (key, item) in fields { + add(key, out); + sent_paths(item, out); + } + } + _ => {} + } } /// Adds `id` to `ids` when it is a loaded library's id (`loaded/<path>`). @@ -389,15 +466,20 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet<Str /// shown, wherever `v` holds them, in every string and key: a library's id, its panel's id /// (`library:swatches:loaded/<path>`), status text, a dialog, the command palette query, a script's /// report and a recorded step. An occurrence followed by a character that continues a path -/// ([`continues_path`]) stays only when the request may read the longer path -/// ([`readable_continuation`]): a readable library's id that begins with a hidden one keeps its path. -fn hide_library_ids(v: &mut Value, ids: &[(String, String)]) { - replace_in_value(v, &hidden_text(ids)); +/// ([`continues_path`]) stays only when the longer text is another loaded library's id +/// ([`known_continuation`]): a readable library's id that begins with a hidden one keeps its path. On +/// Windows the ids and paths are matched in any case of ASCII letters. +fn hide_library_ids(v: &mut Value, hiding: &Hiding) { + let pairs = hidden_text(&hiding.hidden); + if pairs.is_empty() { + return; + } + replace_in_value(v, &Rewrite { pairs, hiding, fold: cfg!(windows) }); } /// The text [`hide_library_ids`] replaces, longest first: each hidden id with the id shown, and the -/// path in it with the file name shown, the path written with either separator too, and with each -/// `\` doubled (a path quoted with `{:?}`). +/// path in it with the file name shown, the path written with either separator too, and with each `\` +/// doubled (a path quoted with `{:?}`). fn hidden_text(ids: &[(String, String)]) -> Vec<(String, String)> { let mut pairs: Vec<(String, String)> = Vec::new(); for (id, shown) in ids { @@ -416,49 +498,174 @@ fn hidden_text(ids: &[(String, String)]) -> Vec<(String, String)> { pairs } -/// Writes each `from` text of `pairs` as its `to` text in every string and key of `v`, at each -/// occurrence not followed by a character that continues a path ([`continues_path`]) into a path the -/// request may read ([`readable_continuation`]). Encoded file data, under a key ending in `Base64` -/// (`dataBase64`, `pngBase64`), stays as it is. -fn replace_in_value(v: &mut Value, pairs: &[(String, String)]) { - if pairs.is_empty() { - return; - } +/// What [`replace_in_value`] writes: the text to replace, what the reply hides, and whether to match +/// in any case of ASCII letters. +struct Rewrite<'a> { + pairs: Vec<(String, String)>, + hiding: &'a Hiding, + fold: bool, +} + +/// The keys of encoded file data in a reply. +const BASE64_FIELDS: [&str; 3] = ["dataBase64", "pngBase64", "thumbnailBase64"]; + +/// Is `s` written in the base64 alphabet, padded to a multiple of 4? +fn is_base64(s: &str) -> bool { + let body = s.trim_end_matches('='); + s.len().is_multiple_of(4) && s.len() - body.len() <= 2 && body.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/') +} + +/// Writes each `from` text of the rewrite's pairs as its `to` text in every string and key of `v`, as +/// [`replace_text`] does. Encoded file data, a base64 string under a key of [`BASE64_FIELDS`], stays +/// as it is. +fn replace_in_value(v: &mut Value, r: &Rewrite) { match v { Value::String(s) => { - if let Some(replaced) = replace_text(s, pairs) { + if let Some(replaced) = replace_text(s, r) { *s = replaced; } } - Value::Array(items) => items.iter_mut().for_each(|item| replace_in_value(item, pairs)), + Value::Array(items) => items.iter_mut().for_each(|item| replace_in_value(item, r)), Value::Object(fields) => { for (key, mut item) in std::mem::take(fields) { - if key.ends_with("Base64") { + if BASE64_FIELDS.contains(&key.as_str()) && item.as_str().is_some_and(is_base64) { fields.insert(key, item); continue; } - replace_in_value(&mut item, pairs); - fields.insert(replace_text(&key, pairs).unwrap_or(key), item); + replace_in_value(&mut item, r); + fields.insert(replace_text(&key, r).unwrap_or(key), item); } } _ => {} } } -/// `s` with the `from` texts of `pairs` written as their `to` texts, as [`replace_in_value`] writes -/// them. `None` when nothing changed. -fn replace_text(s: &str, pairs: &[(String, String)]) -> Option<String> { +/// The byte offsets in `text` at which `needle` starts, not overlapping, in any case of ASCII letters +/// with `fold`. +fn occurrences(text: &str, needle: &str, fold: bool) -> Vec<usize> { + if needle.is_empty() { + return Vec::new(); + } + if fold { + let (text, needle) = (text.to_ascii_lowercase(), needle.to_ascii_lowercase()); + text.match_indices(needle.as_str()).map(|(at, _)| at).collect() + } else { + text.match_indices(needle).map(|(at, _)| at).collect() + } +} + +/// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, at each occurrence +/// not followed by a character that continues a path ([`continues_path`]) into another loaded library's id +/// ([`known_continuation`]). A hidden id or path written with `%XX` escapes (in a `file:` URL) is +/// replaced too; the escapes around it stay. `None` when nothing changed. +fn replace_text(s: &str, r: &Rewrite) -> Option<String> { + let replaced = replace_plain_text(s, r); + let text = replaced.as_deref().unwrap_or(s); + if !text.contains('%') { + return replaced; + } + let Some((decoded, spans)) = decoded_with_spans(text) else { return replaced }; + if decoded == text { + return replaced; + } + // The spans of `text` to replace, found in the decoded text, longest `from` first. + let mut cuts: Vec<(usize, usize, &str)> = Vec::new(); + let mut taken: Vec<(usize, usize)> = Vec::new(); + for (from, to) in &r.pairs { + for at in occurrences(&decoded, from, r.fold) { + let end = at + from.len(); + let free = !taken.iter().any(|&(a, b)| at < b && a < end); + if free && (!continues_path(&decoded[end..]) || !known_continuation(&decoded, at, end, r)) { + taken.push((at, end)); + cuts.push((spans[at].0, spans[end - 1].1, to.as_str())); + } + } + } + if cuts.is_empty() { + return replaced; + } + cuts.sort_unstable_by_key(|&(start, _, _)| start); + let mut out = String::with_capacity(text.len()); + let mut copied = 0; + for (start, stop, to) in cuts { + out.push_str(&text[copied..start]); + out.push_str(to); + copied = stop; + } + out.push_str(&text[copied..]); + Some(out) +} + +/// `text` with its `%XX` escapes decoded, and for each byte of the decoded text the range of `text` +/// it was read from. A decoded byte that is not part of UTF-8 text is read as U+FFFD. +fn decoded_with_spans(text: &str) -> Option<(String, Vec<(usize, usize)>)> { + let bytes = text.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut spans = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + let hex = bytes + .get(i + 1..i + 3) + .filter(|h| bytes[i] == b'%' && h.iter().all(u8::is_ascii_hexdigit)) + .and_then(|h| std::str::from_utf8(h).ok()) + .and_then(|h| u8::from_str_radix(h, 16).ok()); + match hex { + Some(v) => { + out.push(v); + spans.push((i, i + 3)); + i += 3; + } + None => { + out.push(bytes[i]); + spans.push((i, i + 1)); + i += 1; + } + } + } + let mut decoded = String::with_capacity(out.len()); + let mut decoded_spans = Vec::with_capacity(spans.len()); + let mut at = 0; + for chunk in out.utf8_chunks() { + decoded.push_str(chunk.valid()); + decoded_spans.extend_from_slice(spans.get(at..at + chunk.valid().len())?); + at += chunk.valid().len(); + for _ in chunk.invalid() { + decoded.push(char::REPLACEMENT_CHARACTER); + let span = *spans.get(at)?; + decoded_spans.extend([span; 3]); + at += 1; + } + } + Some((decoded, decoded_spans)) +} + +/// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, as [`replace_text`] +/// writes them in text without `%XX` escapes. With the rewrite's `fold`, the ids and paths as written +/// are replaced first, so that two libraries whose paths differ only in letter case keep their own +/// ids, and then the ones written in another case. `None` when nothing changed. +fn replace_plain_text(s: &str, r: &Rewrite) -> Option<String> { + let exact = replace_matches(s, r, false); + if !r.fold { + return exact; + } + replace_matches(exact.as_deref().unwrap_or(s), r, true).or(exact) +} + +/// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, matched in any case +/// of ASCII letters with `fold`. `None` when nothing changed. +fn replace_matches(s: &str, r: &Rewrite, fold: bool) -> Option<String> { let mut out: Option<String> = None; - for (from, to) in pairs { + for (from, to) in &r.pairs { let text = out.as_deref().unwrap_or(s); - if from.is_empty() || !text.contains(from.as_str()) { + let found = occurrences(text, from, fold); + if found.is_empty() { continue; } let mut next = String::with_capacity(text.len()); let mut copied = 0; - for (at, _) in text.match_indices(from.as_str()) { + for at in found { let end = at + from.len(); - if !continues_path(&text[end..]) || !readable_continuation(&text[at..]) { + if !continues_path(&text[end..]) || !known_continuation(text, at, end, r) { next.push_str(&text[copied..at]); next.push_str(to); copied = end; @@ -478,13 +685,19 @@ fn continues_path(rest: &str) -> bool { rest.trim_start_matches(['.', '-', '~']).starts_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '/' | '\\')) } -/// May the request read the longer path that `text` starts with, up to a space, a quote, a bracket, -/// `,`, `;` or `|`, without a `loaded/` before it? A readable library's id that begins with a hidden -/// one stays; a hidden path followed by more path text (`<path>.bak`) is still hidden. -fn readable_continuation(text: &str) -> bool { - let end = text.find(|c: char| c.is_whitespace() || "\"'`()[]{}<>,;|".contains(c)).unwrap_or(text.len()); - let path = &text[..end]; - readable(path.strip_prefix("loaded/").unwrap_or(path)) +/// Is the longer text that the occurrence `text[at..end]` starts, up to the first space, quote, +/// bracket, `,`, `;` or `|` after `end`, another loaded library's id ([`Hiding::readable`]), written +/// with or without its `loaded/`, with no `.` or `..` part? A readable library's id that begins with a +/// hidden one stays; a hidden path followed by more path text (`<path>.bak`) is still hidden. +fn known_continuation(text: &str, at: usize, end: usize, r: &Rewrite) -> bool { + let stop = text[end..].find(|c: char| c.is_whitespace() || "\"'`()[]{}<>,;|".contains(c)).map_or(text.len(), |n| end + n); + let longer = &text[at..stop]; + if longer.split(['/', '\\']).any(|part| part == "." || part == "..") { + return false; + } + let same = |id: &str, want: &str| if r.fold { id.eq_ignore_ascii_case(want) } else { id == want }; + let id = if longer.starts_with("loaded/") { longer.to_string() } else { format!("loaded/{longer}") }; + r.hiding.readable.iter().any(|known| same(known, &id)) } /// Text that a person or a script wrote, by its key: a command palette query, a script's report and a @@ -584,6 +797,19 @@ fn unreadable_absolute(s: &str) -> bool { } } +/// Is `s` written as a path that the request may not read: an absolute path or a `file:` URL +/// ([`unreadable_absolute`]), `~` or a path under it, or a path that starts with `.` or `..` and a +/// separator? It is checked as the file access check reads it, relative to the working folder. Other +/// text with a separator (`a/b`) stays. +fn unreadable_path_like(s: &str) -> bool { + if unreadable_absolute(s) { + return true; + } + let t = s.trim_start(); + let relative = t.trim_end() == "~" || ["~/", "~\\", "./", ".\\", "../", "..\\"].iter().any(|p| t.starts_with(p)); + relative && !readable_value(s) +} + /// May the request read the path `s` names, a path or a `file:` URL ([`file_url_path`])? fn readable_value(s: &str) -> bool { match file_url_path(s) { @@ -646,13 +872,16 @@ fn steps_mut(set: &mut Value) -> impl Iterator<Item = &mut serde_json::Map<Strin /// Takes out of a recorded step's params, at any depth (the commands of a `command.batch` step), the /// paths the request may not read: the value of a path param /// ([`vectorcraft_engine::file_access::is_path_key`]: `path`, `file`, `diffPath`, `destinationFolder` -/// and the like), each path of a list under such a param, and under any other param an absolute path -/// or a `file:` URL ([`unreadable_absolute`]). +/// and the like), each path of a list under such a param, and under any other param, as a key too, a +/// string written as a path ([`unreadable_path_like`]). fn leave_out_unreadable_params(params: &mut serde_json::Map<String, Value>) { use vectorcraft_engine::file_access::is_path_key; - params.retain(|key, v| match v.as_str() { - Some(p) => !unreadable_absolute(p) && !(is_path_key(key) && !readable_value(p)), - None => true, + params.retain(|key, v| { + !unreadable_path_like(key) + && match v.as_str() { + Some(p) => !unreadable_path_like(p) && !(is_path_key(key) && !readable_value(p)), + None => true, + } }); for (key, v) in params.iter_mut() { if let Value::Array(paths) = v @@ -664,13 +893,13 @@ fn leave_out_unreadable_params(params: &mut serde_json::Map<String, Value>) { } } -/// [`leave_out_unreadable_params`] on each object `v` holds, and an absolute path or a `file:` URL -/// the request may not read taken out of each list. +/// [`leave_out_unreadable_params`] on each object `v` holds, and a string written as a path that the +/// request may not read ([`unreadable_path_like`]) taken out of each list. fn leave_out_unreadable_values(v: &mut Value) { match v { Value::Object(fields) => leave_out_unreadable_params(fields), Value::Array(items) => { - items.retain(|item| !item.as_str().is_some_and(unreadable_absolute)); + items.retain(|item| !item.as_str().is_some_and(unreadable_path_like)); items.iter_mut().for_each(leave_out_unreadable_values); } _ => {} @@ -770,38 +999,58 @@ const DOCKING_ACTION: &str = "action"; /// shows for it ([`unreadable_library_ids`]) both ways: a request may name it or its panel by that id /// in a [`LIBRARY_PARAMS`] param, and the reply shows that id. Under a read root, the reply shows each /// such library's id and path as `ui.inspect` does ([`hide_library_ids`]) in every string and key, -/// errors included: the libraries hidden after it ran, and before it for a request that names one. -/// A reply with no `/` or `\` in it is not searched, since every hidden path holds a separator. +/// errors included, an error from a panic too: the libraries hidden before it ran and after it. The +/// error of a failed request also hides the paths outside the read root that the request sent +/// ([`sent_paths`]). A reply with no `/`, `\` or `%` in it is not searched, since every hidden path +/// holds a separator, written or escaped. A deferred reply (a screenshot, a `ui.pointer` reply sent +/// after the tool's edit) holds no library id or path. pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { if vectorcraft_engine::file_access::current().is_none() { return dispatch(app, ctx, req); } - let before = if names_a_loaded_library(&req.params) { unreadable_library_ids(app) } else { Vec::new() }; - let shown: Vec<_> = before.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect(); + // Pointer, key and text input does not load, close or rename a library: the libraries hidden after + // it ran are the ones hidden before, so frequent input does not read the UI state twice. + let before = if INPUT_METHODS.contains(&req.method.as_str()) { Hiding::default() } else { unreadable_library_ids(app) }; + let shown: Vec<_> = before.hidden.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect(); let mut params = req.params.clone(); - let outcome = if !shown.is_empty() && swap_library_params(&mut params, &shown) { - let request = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq }; - dispatch(app, ctx, &request) - } else { - dispatch(app, ctx, req) + let swapped = !shown.is_empty() && names_a_loaded_library(&req.params) && swap_library_params(&mut params, &shown); + let ran = vectorcraft_engine::guard::catch_panic(|| { + if swapped { + let request = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq }; + dispatch(app, ctx, &request) + } else { + dispatch(app, ctx, req) + } + }); + let outcome = match ran { + Ok(outcome) => outcome, + Err(msg) => { + app.picks.recover(); + app.session.set_replaying(false); + err(format!("internal error: {msg} (please report this bug)")) + } }; let Outcome::Done(mut reply) = outcome else { return outcome }; if !holds_a_separator(&reply) { return Outcome::Done(reply); } - let mut ids = unreadable_library_ids(app); - for pair in before { - if !ids.iter().any(|(id, _)| *id == pair.0) { - ids.push(pair); - } + let mut hiding = unreadable_library_ids(app); + hiding.merge(before); + if reply.get("ok").and_then(Value::as_bool) == Some(false) { + let mut sent = Vec::new(); + sent_paths(&req.params, &mut sent); + hiding.set_hidden(sent, cfg!(windows)); } - hide_library_ids(&mut reply, &ids); + hide_library_ids(&mut reply, &hiding); Outcome::Done(reply) } -/// Does a string or a key of `v`, at any depth, hold a `/` or a `\`? +/// The control methods that send synthetic input. +const INPUT_METHODS: [&str; 7] = ["ui.pointer", "ui.click", "ui.drag", "ui.move", "ui.wheel", "ui.key", "ui.text"]; + +/// Does a string or a key of `v`, at any depth, hold a `/`, a `\` or a `%`? fn holds_a_separator(v: &Value) -> bool { - let has = |s: &str| s.contains(['/', '\\']); + let has = |s: &str| s.contains(['/', '\\', '%']); match v { Value::String(s) => has(s), Value::Array(items) => items.iter().any(holds_a_separator), @@ -879,6 +1128,18 @@ fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) hide_library_ids(&mut docking, &ids); ok(docking) } + // A request that stops with a panic quoting the status text, after clearing the Color Guide's + // limit with `forget`. + #[cfg(test)] + "test.panic" => { + if p.get("forget").and_then(Value::as_bool) == Some(true) { + app.ui.color_guide_limit.clear(); + } + panic!("failed on {}", app.ui.status) + } + // A request that succeeds with the status text. + #[cfg(test)] + "test.status" => ok(json!(app.ui.status)), "ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()), "ui.contextMenu.list" => ok(serde_json::to_value(crate::menus::context_entries(app)).unwrap_or_default()), "ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))), diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index bc1dab6c42..d06f9eb83e 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -18,29 +18,125 @@ fn input_request(app: &mut VectorcraftApp, method: &str, params: Value) -> Value fn a_reply_hides_a_hidden_library_whatever_surrounds_it() { let path = std::env::temp_dir().join("vc-named-reply").join("b.svg").to_string_lossy().into_owned(); let id = format!("loaded/{path}"); - let hidden = [(id.clone(), "loaded/b.svg".to_string())]; + let hidden = hiding(&[(&id, "loaded/b.svg")], &[]); let mut reply = json!({"ok": false, "error": format!("can't add ( {id} ) or '{id}', from {path}; also {id}.bak and {path:?}")}); - let root = std::env::temp_dir().join(format!("vc-named-reply-root-{}", std::process::id())); - std::fs::create_dir_all(&root).unwrap(); - let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); - vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden)); - let _ = std::fs::remove_dir_all(root); + hide_under_a_root("named-reply", &mut reply, &hidden); assert_eq!(reply["error"], "can't add ( loaded/b.svg ) or 'loaded/b.svg', from b.svg; also loaded/b.svg.bak and \"b.svg\"", "{reply}"); } -/// Encoded file data in a reply (`dataBase64`) stays as it is when it holds a hidden library's path. -#[test] -fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() { - let hidden = [("loaded/assets/icon".to_string(), "loaded/icon".to_string())]; - let data = "QUJDassets/iconREVG"; - let mut reply = json!({"ok": true, "result": {"files": [{"name": "a.png", "dataBase64": data}], "status": "from assets/icon"}}); - let root = std::env::temp_dir().join(format!("vc-base64-root-{}", std::process::id())); +/// A [`super::Hiding`] with `hidden` ids, each with its shown id, and `readable` ids. +fn hiding(hidden: &[(&str, &str)], readable: &[&str]) -> super::Hiding { + let mut hidden: Vec<(String, String)> = hidden.iter().map(|(id, shown)| (id.to_string(), shown.to_string())).collect(); + hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); + super::Hiding { hidden, readable: readable.iter().map(|id| id.to_string()).collect() } +} + +/// [`super::hide_library_ids`] on `reply`, under a read root made for `tag`. +fn hide_under_a_root(tag: &str, reply: &mut Value, hidden: &super::Hiding) { + let root = std::env::temp_dir().join(format!("vc-{tag}-root-{}", std::process::id())); std::fs::create_dir_all(&root).unwrap(); let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); - vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden)); + vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(reply, hidden)); let _ = std::fs::remove_dir_all(root); - assert_eq!(reply["result"]["files"][0]["dataBase64"], data, "{reply}"); +} + +/// Encoded file data in a reply, a base64 string under `dataBase64`, `pngBase64` or +/// `thumbnailBase64`, stays as it is when it holds a hidden library's path. Text that is not base64 +/// under such a key, an object or a list under it, and any other key ending in `Base64` are searched. +#[test] +fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() { + let hidden = hiding(&[("loaded/assets/icon", "loaded/icon")], &[]); + let data = "QUJDassets/iconREVGxQQ=="; + let mut reply = json!({"ok": true, "result": { + "files": [{"name": "a.png", "dataBase64": data, "pngBase64": "assets/icon.png", "thumbnailBase64": data}], + "status": "from assets/icon", + "dataBase64": {"note": "from assets/icon"}, + "pngBase64": ["from assets/icon"], + "otherBase64": data, + }}); + hide_under_a_root("base64", &mut reply, &hidden); + let files = &reply["result"]["files"][0]; + assert_eq!((&files["dataBase64"], &files["thumbnailBase64"]), (&json!(data), &json!(data)), "{reply}"); + assert_eq!(files["pngBase64"], "icon.png", "{reply}"); assert_eq!(reply["result"]["status"], "from icon", "{reply}"); + assert_eq!(reply["result"]["dataBase64"]["note"], "from icon", "{reply}"); + assert_eq!(reply["result"]["pngBase64"][0], "from icon", "{reply}"); + assert_eq!(reply["result"]["otherBase64"], "QUJDiconREVGxQQ==", "{reply}"); +} + +/// A hidden path followed by more text stays only when the longer text is another loaded library's +/// id: text after the path that the request may read, a `..` part and the part of the path before a +/// space do not keep it. +#[test] +fn a_reply_keeps_a_hidden_path_only_inside_a_known_library_id() { + let base = std::env::temp_dir().join(format!("vc-continuation-{}", std::process::id())); + let root = base.join("in"); + std::fs::create_dir_all(&root).unwrap(); + let hidden_path = base.join("in x").join("b.svg").to_string_lossy().into_owned(); + let other = base.join("outside").join("b.svg").to_string_lossy().into_owned(); + let known = format!("loaded/{other}{}sub.svg", std::path::MAIN_SEPARATOR); + let hidden = hiding(&[(&format!("loaded/{hidden_path}"), "loaded/b.svg"), (&format!("loaded/{other}"), "loaded/b.svg (2)")], &[&known]); + let up = ["", "..", "..", "in", "a.svg"].join(std::path::MAIN_SEPARATOR_STR); + let mut reply = json!({"error": format!("kept {hidden_path}.bak, {other}{up} and {known}")}); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden)); + let _ = std::fs::remove_dir_all(base); + assert_eq!(reply["error"], json!(format!("kept b.svg.bak, b.svg (2){up} and {known}")), "{reply}"); +} + +/// With matching in any case (as on Windows), a hidden path written in other letter cases is hidden +/// too; a path in a `file:` URL with `%XX` escapes is hidden on every platform. +#[test] +fn a_reply_hides_a_path_in_any_case_and_escaped() { + let hidden = hiding(&[("loaded/C:/Libs/My Swatches/b.svg", "loaded/b.svg")], &[]); + let mut reply = json!({"error": "from c:/LIBS/my swatches/B.SVG and file:///C:/Libs/My%20Swatches/b.svg"}); + let pairs = super::hidden_text(&hidden.hidden); + let rewrite = super::Rewrite { pairs, hiding: &hidden, fold: true }; + super::replace_in_value(&mut reply, &rewrite); + assert_eq!(reply["error"], "from b.svg and file:///b.svg", "{reply}"); + // Two libraries whose paths differ only in letter case keep their own ids. + let two = hiding(&[("loaded//libs/b.svg", "loaded/b.svg"), ("loaded//libs/B.svg", "loaded/B.svg")], &[]); + let mut reply = json!({"error": "x /libs/B.svg y /libs/b.svg z /LIBS/b.svg"}); + super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&two.hidden), hiding: &two, fold: true }); + assert_eq!(reply["error"], "x B.svg y b.svg z b.svg", "{reply}"); + let mut reply = json!({"error": "file:///C:/Libs/My%20Swatches/b.svg"}); + hide_under_a_root("escaped", &mut reply, &hidden); + assert_eq!(reply["error"], "file:///b.svg", "{reply}"); + let hidden = hiding(&[("loaded//libs/caf\u{e9}/b.svg", "loaded/b.svg")], &[]); + let mut reply = json!({"error": "file:///libs/caf%C3%a9/b.svg and file:///libs%2Fcaf%c3%A9%2Fb.svg?next=%2Fdocs&x=%FF at 100%25"}); + hide_under_a_root("escaped-mixed", &mut reply, &hidden); + assert_eq!(reply["error"], "file://b.svg and file://b.svg?next=%2Fdocs&x=%FF at 100%25", "{reply}"); +} + +/// A path a failed request sent takes the place of a hidden library's id for the same path, written +/// with the other separator, and in another letter case when matching in any case (as on Windows). +#[test] +fn a_sent_path_replaces_a_hidden_id_for_the_same_path() { + let mut hidden = hiding(&[(r"loaded/C:\Libs\b.svg", "loaded/b.svg (2)"), ("loaded//other/b.svg", "loaded/b.svg")], &[]); + hidden.set_hidden(vec![("loaded/c:/libs/B.svg".to_string(), "loaded/B.svg".to_string())], true); + let ids: Vec<&str> = hidden.hidden.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(ids, ["loaded/c:/libs/B.svg", "loaded//other/b.svg"]); + hidden.set_hidden(vec![("loaded//OTHER/b.svg".to_string(), "loaded/b.svg".to_string())], false); + assert_eq!(hidden.hidden.len(), 3, "{hidden:?}"); +} + +/// The paths outside the read root that a request sends, as a path, a `file:` URL, under `~` or +/// starting with `../`, are hidden by their file names; a URL that names another host, plain text and +/// a path inside the root are not. +#[test] +fn a_request_sends_paths_that_an_error_hides() { + let base = std::env::temp_dir().join(format!("vc-sent-paths-{}", std::process::id())); + let root = base.join("in"); + std::fs::create_dir_all(&root).unwrap(); + let inside = root.join("a.svg").to_string_lossy().into_owned(); + let keep = json!(["a/b", inside, "file://host/g.svg"]); + let params = json!({"path": "/x/my lib/b.svg", "list": ["file:///y/c%20d.svg", "~/e.svg", "../f.svg"], "keep": keep}); + let mut sent = Vec::new(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(¶ms, &mut sent)); + let _ = std::fs::remove_dir_all(base); + let pair = |path: &str, name: &str| (format!("loaded/{path}"), format!("loaded/{name}")); + assert_eq!(sent, [pair("/y/c d.svg", "c d.svg"), pair("~/e.svg", "e.svg"), pair("../f.svg", "f.svg"), pair("/x/my lib/b.svg", "b.svg")]); } /// Under a read root, an absolute path or a `file:` URL outside it is unreadable; an empty string, a diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 904068cfc8..efa2fce9d0 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -675,6 +675,118 @@ fn ui_inspect_keeps_a_hidden_library_off_a_loaded_librarys_id() { let _ = std::fs::remove_dir_all(base); } +/// Hidden libraries with the same file name are numbered in the order they were loaded, not in the +/// order of their paths. +#[test] +fn hidden_libraries_are_numbered_in_load_order() { + let (mut app, tx, base) = confined("hidden-load-order"); + let gpl = "GIMP Palette\nName: p\n0 0 0 Black\n"; + for dir in ["z", "a"] { + std::fs::create_dir_all(base.join("outside").join(dir)).unwrap(); + let path = base.join("outside").join(dir).join("b.gpl"); + std::fs::write(&path, gpl).unwrap(); + app.session.execute("swatch.library.load", &json!({"path": s(path)})).unwrap(); + } + app.ui.color_guide_limit = format!("loaded/{}", s(base.join("outside").join("a").join("b.gpl"))); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.gpl (2)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A hidden library keeps the id a reply showed for it when another library with the same file name is +/// loaded later. +#[test] +fn a_hidden_library_keeps_its_id_when_another_with_its_name_loads() { + let (mut app, tx, base) = confined("hidden-stable-id"); + let first = format!("loaded/{}", s(base.join("outside").join("a").join("b.gpl"))); + let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": first})).unwrap(); + app.ui.library_panels = vec![panel]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/b.gpl", "{r}"); + std::fs::create_dir_all(base.join("outside").join("z")).unwrap(); + let path = base.join("outside").join("z").join("b.gpl"); + std::fs::write(&path, "GIMP Palette\nName: p\n0 0 0 Black\n").unwrap(); + app.session.execute("swatch.library.load", &json!({"path": s(path.clone())})).unwrap(); + app.ui.color_guide_limit = format!("loaded/{}", s(path)); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/b.gpl", "{r}"); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.gpl (2)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// An error from a panic hides a hidden library's path, also when the request stopped referring to +/// the library before it panicked. +#[test] +fn a_panic_reply_hides_a_library_hidden_before_the_request() { + let (mut app, tx, base) = confined("panic-hidden"); + let path = s(base.join("outside/b.svg")); + app.ui.color_guide_limit = format!("loaded/{path}"); + app.ui.status = format!("Loaded {path}"); + let r = request(&mut app, &tx, "test.panic", json!({"forget": true})); + assert_eq!(r["error"], "internal error: failed on Loaded b.svg (please report this bug)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// An error shows a path outside the read root that the request sent by its file name, whether or not +/// a hidden library was loaded from that path, so that it reads the same either way. A path the +/// request sends does not show the app's own text that holds it, in an error or a result. +#[test] +fn an_error_hides_a_sent_path_whether_or_not_a_library_was_loaded_from_it() { + let (mut app, tx, base) = confined("client-echo"); + let path = s(base.join("outside/b.svg")); + // Another hidden library with the same file name comes first, so this one shows as `b.svg (2)`. + let first = format!("loaded/{}", s(base.join("outside/z/b.svg"))); + let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": first})).unwrap(); + app.ui.library_panels = vec![panel]; + app.ui.color_guide_limit = format!("loaded/{path}"); + app.ui.status = format!("Loaded {path}"); + let unloaded = [(s(base.join("outside/y/b.svg")), "b.svg"), (s(base.join("outside/c.svg")), "c.svg")]; + for (sent, name) in [(path.clone(), "b.svg")].into_iter().chain(unloaded) { + let r = request(&mut app, &tx, "app.open", json!({"path": sent})); + assert!(r["error"].as_str().is_some_and(|e| e.ends_with(&format!(": {name}"))), "{r}"); + } + let r = request(&mut app, &tx, "test.status", json!({"guess": path})); + assert_eq!(r["result"], "Loaded b.svg (2)", "{r}"); + let r = request(&mut app, &tx, "test.panic", json!({"guess": path})); + assert_eq!(r["error"], "internal error: failed on Loaded b.svg (please report this bug)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A reply whose only trace of a hidden library's path is a `file:` URL with every separator escaped +/// shows the file name. +#[test] +fn a_reply_hides_a_path_with_escaped_separators() { + let (mut app, tx, base) = confined("escaped-separators"); + let path = s(base.join("outside/b.svg")); + app.ui.color_guide_limit = format!("loaded/{path}"); + app.ui.status = format!("from file:{}", path.replace('/', "%2F").replace('\\', "%5C")); + let r = request(&mut app, &tx, "test.status", json!({})); + assert_eq!(r["result"], "from file:b.svg", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` leaves out of a recorded action's steps a path that starts with `./` or `../`, `~` and +/// a path under `~` outside the read root under any param, as a key too, and in any list. A URL and +/// other text with a separator stay. +#[test] +fn ui_inspect_leaves_out_relative_paths_in_recorded_steps() { + let (mut app, tx, base) = confined("inspect-relative"); + let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ + "name": "set", + "actions": [{"name": "files", "steps": [ + ["x.y", {"target": "~/b.svg", "rel": "../outside/b.svg", "home": "~", "text": "a/b", "url": "https://example.com/a"}], + ["x.z", {"list": [".\\b.svg", "a/b"], "../k": 1}], + ]}], + })) + .unwrap(); + app.ui.action_sets = vec![set]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"]; + let want = json!([["x.y", {"text": "a/b", "url": "https://example.com/a"}], ["x.z", {"list": ["a/b"]}]]); + assert_eq!(steps, &want, "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A closed panel's former place, as `ui.docking_hidden` keeps it. fn hidden_place() -> craft_ui::docking::Location<String> { serde_json::from_value(json!({"floating": null, "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null})).unwrap() @@ -735,7 +847,7 @@ fn ui_inspect_and_ui_docking_give_a_hidden_library_the_same_id() { let ui = &r["result"]["ui"]; assert_eq!( (ui["color_guide_limit"].as_str(), ui["open_panel"].as_str()), - (Some("loaded/b.svg"), Some("library:swatches:loaded/b.svg (2)")), + (Some("loaded/b.svg (2)"), Some("library:swatches:loaded/b.svg")), "{r}" ); let d = request(&mut app, &tx, "ui.docking", json!({})); diff --git a/docs/mcp.md b/docs/mcp.md index a4e322818f..0399e7a8d9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -145,9 +145,10 @@ the suite) keep the agent to the folders you give it: documents still being read; in `ui.recent_files`, the recent files; a library panel's `path` in `ui.library_panels`; in a recorded action's steps in `ui.action_sets`, also in a batch step's commands, the `path`, `paths`, `file`, `files` and `folder` params, a param whose name ends in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`, - `destinationFolder`), the entries of a list under such a param, and under any other param or in any list an - absolute path or a `file:` URL outside the roots (any case of `file:`, `%XX` escapes decoded; a URL that names - another host is always left out); and the folder + `destinationFolder`), the entries of a list under such a param, and under any other param, as a key or in any + list, an absolute path or a `file:` URL outside the roots (any case of `file:`, `%XX` escapes decoded; a URL that + names another host is always left out), and `~`, a path under `~` or a path that starts with `./` or `../` + (either separator) that resolves outside the roots from the app's working folder; and the folder preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`; an empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the @@ -165,9 +166,14 @@ the suite) keep the agent to the folders you give it: reopens its panel), and the reply to such a request shows that id and file name wherever the library's id or path appears, an error included. Every reply under the read roots shows a hidden library's id as the id shown and its path as the file name in every string and key, the status text (`ui.status`), dialogs, the command palette query - (`ui.palette_query`) and a script's report (`scriptReport`) included. A longer path that begins with a hidden one - keeps its text only when it lies inside the read roots, so a readable library's id that begins with a hidden one - stays. Encoded file data (`dataBase64`, `pngBase64`) is sent as it is. + (`ui.palette_query`), a script's report (`scriptReport`) and the error from an internal fault included, for the + libraries hidden before the request ran and after it. The path is also matched with `%XX` escapes as a `file:` + URL writes it, and on Windows in any case of its letters. A longer text that begins with a hidden id or path + keeps it only when the longer text is another loaded library's id with no `.` or `..` part, so a readable + library's id that begins with a hidden one stays. The error of a failed request also shows a path outside the + read roots that the request sent by its file name, whether or not a library was loaded from it, so that the + error reads the same either way. Encoded file data, a base64 string under `dataBase64`, + `pngBase64` or `thumbnailBase64`, is sent as it is. - `swatch.library.list`, `brush.libraries`, `symbol.libraries` and `graphicStyle.libraries` list every library but leave out the paths outside the read roots: a library's `path` and the user library folder in `userFolder` are `null` there. A library loaded from a file outside the roots is listed as `loaded/<file name>`, numbered From 38184805b4aad36e3faa48611f6a7a7786034354 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 16:43:49 -0800 Subject: [PATCH 30/92] MCP: a failed request's error hides a file URL it sent that names another host The error shows the URL's file name, as it does for a local path the request sent outside the read roots. --- crates/ui-egui/src/control.rs | 3 ++- crates/ui-egui/src/control/tests.rs | 17 ++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 3fba434993..55f9ec68e6 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -410,9 +410,10 @@ fn sent_paths(v: &Value, out: &mut Vec<(String, String)>) { if !unreadable_path_like(s) { return; } + // A URL that names another host as the path after its scheme (`//host/share/…`). let path = match file_url_path(s) { Some(Some(path)) => path, - Some(None) => return, + Some(None) => percent_decoded(&s[5..]), None => s.to_string(), }; let Some(name) = path.rsplit(['/', '\\']).find(|part| !part.is_empty()).filter(|name| *name != path) else { return }; diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index d06f9eb83e..e3a129f824 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -120,9 +120,9 @@ fn a_sent_path_replaces_a_hidden_id_for_the_same_path() { assert_eq!(hidden.hidden.len(), 3, "{hidden:?}"); } -/// The paths outside the read root that a request sends, as a path, a `file:` URL, under `~` or -/// starting with `../`, are hidden by their file names; a URL that names another host, plain text and -/// a path inside the root are not. +/// The paths outside the read root that a request sends, as a path, a `file:` URL (also one that names +/// another host), under `~` or starting with `../`, are hidden by their file names; plain text and a +/// path inside the root are not. #[test] fn a_request_sends_paths_that_an_error_hides() { let base = std::env::temp_dir().join(format!("vc-sent-paths-{}", std::process::id())); @@ -130,13 +130,20 @@ fn a_request_sends_paths_that_an_error_hides() { std::fs::create_dir_all(&root).unwrap(); let inside = root.join("a.svg").to_string_lossy().into_owned(); let keep = json!(["a/b", inside, "file://host/g.svg"]); - let params = json!({"path": "/x/my lib/b.svg", "list": ["file:///y/c%20d.svg", "~/e.svg", "../f.svg"], "keep": keep}); + let params = json!({"path": "/x/my lib/b.svg", "list": ["file:///y/c%20d.svg", "~/e.svg", "../f.svg"], "other": keep}); let mut sent = Vec::new(); let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(¶ms, &mut sent)); let _ = std::fs::remove_dir_all(base); let pair = |path: &str, name: &str| (format!("loaded/{path}"), format!("loaded/{name}")); - assert_eq!(sent, [pair("/y/c d.svg", "c d.svg"), pair("~/e.svg", "e.svg"), pair("../f.svg", "f.svg"), pair("/x/my lib/b.svg", "b.svg")]); + let want = [ + pair("/y/c d.svg", "c d.svg"), + pair("~/e.svg", "e.svg"), + pair("../f.svg", "f.svg"), + pair("//host/g.svg", "g.svg"), + pair("/x/my lib/b.svg", "b.svg"), + ]; + assert_eq!(sent, want); } /// Under a read root, an absolute path or a `file:` URL outside it is unreadable; an empty string, a From 13bee0d6ef81f9e0d1a247e187a2f3cd6bdc19d8 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 17:07:30 -0800 Subject: [PATCH 31/92] MCP: a readable library id keeps its text when a space follows the hidden part A hidden id or path stays only where it begins another loaded library's whole id with no `.` or `..` part, also when that id has a space after the hidden part. --- crates/ui-egui/src/control.rs | 41 ++++++++++++++--------------- crates/ui-egui/src/control/tests.rs | 6 +++++ docs/mcp.md | 5 ++-- 3 files changed, 28 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 55f9ec68e6..864800e660 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -366,7 +366,7 @@ struct Hiding { /// shown instead ([`unreadable_library_ids`]). hidden: Vec<(String, String)>, /// The other loaded library ids the UI state and the session hold: an occurrence of a hidden id or - /// path that continues into one of them stays ([`known_continuation`]). + /// path that begins one of them stays ([`begins_known_id`]). readable: std::collections::BTreeSet<String>, } @@ -466,9 +466,8 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet<Str /// Writes each id of `ids` as the id the reply shows instead, and the path in it as the file name /// shown, wherever `v` holds them, in every string and key: a library's id, its panel's id /// (`library:swatches:loaded/<path>`), status text, a dialog, the command palette query, a script's -/// report and a recorded step. An occurrence followed by a character that continues a path -/// ([`continues_path`]) stays only when the longer text is another loaded library's id -/// ([`known_continuation`]): a readable library's id that begins with a hidden one keeps its path. On +/// report and a recorded step. An occurrence that begins another loaded library's id stays +/// ([`begins_known_id`]): a readable library's id that begins with a hidden one keeps its path. On /// Windows the ids and paths are matched in any case of ASCII letters. fn hide_library_ids(v: &mut Value, hiding: &Hiding) { let pairs = hidden_text(&hiding.hidden); @@ -556,8 +555,8 @@ fn occurrences(text: &str, needle: &str, fold: bool) -> Vec<usize> { } /// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, at each occurrence -/// not followed by a character that continues a path ([`continues_path`]) into another loaded library's id -/// ([`known_continuation`]). A hidden id or path written with `%XX` escapes (in a `file:` URL) is +/// that does not begin another loaded library's id ([`begins_known_id`]). A hidden id or path written +/// with `%XX` escapes (in a `file:` URL) is /// replaced too; the escapes around it stay. `None` when nothing changed. fn replace_text(s: &str, r: &Rewrite) -> Option<String> { let replaced = replace_plain_text(s, r); @@ -576,7 +575,7 @@ fn replace_text(s: &str, r: &Rewrite) -> Option<String> { for at in occurrences(&decoded, from, r.fold) { let end = at + from.len(); let free = !taken.iter().any(|&(a, b)| at < b && a < end); - if free && (!continues_path(&decoded[end..]) || !known_continuation(&decoded, at, end, r)) { + if free && !begins_known_id(&decoded, at, end, r) { taken.push((at, end)); cuts.push((spans[at].0, spans[end - 1].1, to.as_str())); } @@ -666,7 +665,7 @@ fn replace_matches(s: &str, r: &Rewrite, fold: bool) -> Option<String> { let mut copied = 0; for at in found { let end = at + from.len(); - if !continues_path(&text[end..]) || !known_continuation(text, at, end, r) { + if !begins_known_id(text, at, end, r) { next.push_str(&text[copied..at]); next.push_str(to); copied = end; @@ -686,19 +685,19 @@ fn continues_path(rest: &str) -> bool { rest.trim_start_matches(['.', '-', '~']).starts_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '/' | '\\')) } -/// Is the longer text that the occurrence `text[at..end]` starts, up to the first space, quote, -/// bracket, `,`, `;` or `|` after `end`, another loaded library's id ([`Hiding::readable`]), written -/// with or without its `loaded/`, with no `.` or `..` part? A readable library's id that begins with a -/// hidden one stays; a hidden path followed by more path text (`<path>.bak`) is still hidden. -fn known_continuation(text: &str, at: usize, end: usize, r: &Rewrite) -> bool { - let stop = text[end..].find(|c: char| c.is_whitespace() || "\"'`()[]{}<>,;|".contains(c)).map_or(text.len(), |n| end + n); - let longer = &text[at..stop]; - if longer.split(['/', '\\']).any(|part| part == "." || part == "..") { - return false; - } - let same = |id: &str, want: &str| if r.fold { id.eq_ignore_ascii_case(want) } else { id == want }; - let id = if longer.starts_with("loaded/") { longer.to_string() } else { format!("loaded/{longer}") }; - r.hiding.readable.iter().any(|known| same(known, &id)) +/// Does the occurrence `text[at..end]` begin a longer loaded library id ([`Hiding::readable`]), written +/// with or without its `loaded/` and with no `.` or `..` part, that ends where the text stops +/// continuing a path ([`continues_path`])? +/// A readable library's id that begins with a hidden one stays, also with a space after the hidden +/// part; a hidden path followed by other path text (`<path>.bak`, `<path>/../x`) is still hidden. +fn begins_known_id(text: &str, at: usize, end: usize, r: &Rewrite) -> bool { + let rest = &text[at..]; + let same = |a: &str, b: &str| if r.fold { a.eq_ignore_ascii_case(b) } else { a == b }; + let dotted = |id: &str| id.split(['/', '\\']).any(|part| part == "." || part == ".."); + r.hiding.readable.iter().filter(|known| !dotted(known)).any(|known| { + let id = if rest.starts_with("loaded/") { known.as_str() } else { known.strip_prefix("loaded/").unwrap_or(known) }; + id.len() > end - at && rest.get(..id.len()).is_some_and(|head| same(head, id)) && !continues_path(&rest[id.len()..]) + }) } /// Text that a person or a script wrote, by its key: a command palette query, a script's report and a diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index e3a129f824..026b51bed6 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -82,6 +82,12 @@ fn a_reply_keeps_a_hidden_path_only_inside_a_known_library_id() { vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden)); let _ = std::fs::remove_dir_all(base); assert_eq!(reply["error"], json!(format!("kept b.svg.bak, b.svg (2){up} and {known}")), "{reply}"); + // A readable library's id with a space after the hidden part stays whole. + // A readable id with a `..` part does not keep the hidden part. + let hidden = hiding(&[("loaded//art/in", "loaded/in")], &["loaded//art/in work/a.svg", "loaded//art/in/../ok/c.svg"]); + let mut reply = json!({"error": "x loaded//art/in work/a.svg, /art/in work/a.svg and /art/in work; /art/in/../ok/c.svg"}); + hide_under_a_root("space-continuation", &mut reply, &hidden); + assert_eq!(reply["error"], "x loaded//art/in work/a.svg, /art/in work/a.svg and in work; in/../ok/c.svg", "{reply}"); } /// With matching in any case (as on Windows), a hidden path written in other letter cases is hidden diff --git a/docs/mcp.md b/docs/mcp.md index 0399e7a8d9..d53ee2289d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -168,9 +168,8 @@ the suite) keep the agent to the folders you give it: path as the file name in every string and key, the status text (`ui.status`), dialogs, the command palette query (`ui.palette_query`), a script's report (`scriptReport`) and the error from an internal fault included, for the libraries hidden before the request ran and after it. The path is also matched with `%XX` escapes as a `file:` - URL writes it, and on Windows in any case of its letters. A longer text that begins with a hidden id or path - keeps it only when the longer text is another loaded library's id with no `.` or `..` part, so a readable - library's id that begins with a hidden one stays. The error of a failed request also shows a path outside the + URL writes it, and on Windows in any case of its letters. A hidden id or path stays only where it begins + another loaded library's whole id with no `.` or `..` part, so a readable library's id that begins with a hidden one stays. The error of a failed request also shows a path outside the read roots that the request sent by its file name, whether or not a library was loaded from it, so that the error reads the same either way. Encoded file data, a base64 string under `dataBase64`, `pngBase64` or `thumbnailBase64`, is sent as it is. From 61771e4ead0d72e60b5de5ea3736be35e2445d36 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 17:22:51 -0800 Subject: [PATCH 32/92] MCP: a screenshot's reply hides library paths outside the read roots The reply is sent after the request was handled, and now hides what any other reply hides. --- crates/ui-egui/src/app/screenshot.rs | 9 ++++--- crates/ui-egui/src/control.rs | 34 +++++++++++++++++++------- crates/ui-egui/src/tests/automation.rs | 20 +++++++++++++++ docs/mcp.md | 4 +-- 4 files changed, 53 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/app/screenshot.rs b/crates/ui-egui/src/app/screenshot.rs index ce97fc747f..c34d022e50 100644 --- a/crates/ui-egui/src/app/screenshot.rs +++ b/crates/ui-egui/src/app/screenshot.rs @@ -157,9 +157,12 @@ impl VectorcraftApp { let pending = self.pending_screenshots.remove(index); self.screenshot_reserved = self.screenshot_reserved.saturating_sub(pending.reserved_bytes); let roots = self.automation_roots.clone(); - let _ = pending - .reply - .send(file_access::confine(roots.as_ref(), || control::save_screenshot(self, &image, pending.path.as_deref(), pending.data))); + let reply = file_access::confine(roots.as_ref(), || { + let mut reply = control::save_screenshot(self, &image, pending.path.as_deref(), pending.data); + control::hide_in_deferred_reply(self, &mut reply, &json!({"path": pending.path})); + reply + }); + let _ = pending.reply.send(reply); } Err(e) => { let error = format!("the screenshot could not be used: {e}"); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 864800e660..ee24d563bf 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1002,8 +1002,9 @@ const DOCKING_ACTION: &str = "action"; /// errors included, an error from a panic too: the libraries hidden before it ran and after it. The /// error of a failed request also hides the paths outside the read root that the request sent /// ([`sent_paths`]). A reply with no `/`, `\` or `%` in it is not searched, since every hidden path -/// holds a separator, written or escaped. A deferred reply (a screenshot, a `ui.pointer` reply sent -/// after the tool's edit) holds no library id or path. +/// holds a separator, written or escaped. A screenshot's reply is hidden when it is sent +/// ([`hide_in_deferred_reply`]); a `ui.pointer` reply sent after the tool's edit holds no library id +/// or path. pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { if vectorcraft_engine::file_access::current().is_none() { return dispatch(app, ctx, req); @@ -1031,18 +1032,33 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques } }; let Outcome::Done(mut reply) = outcome else { return outcome }; - if !holds_a_separator(&reply) { - return Outcome::Done(reply); + hide_in_reply(app, &mut reply, &req.params, before); + Outcome::Done(reply) +} + +/// Hides in `reply` the libraries hidden now and in `before`, and for a failed request the paths +/// outside the read root that `sent`, the request's params, holds ([`handle`]). +fn hide_in_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value, before: Hiding) { + if !holds_a_separator(reply) { + return; } let mut hiding = unreadable_library_ids(app); hiding.merge(before); if reply.get("ok").and_then(Value::as_bool) == Some(false) { - let mut sent = Vec::new(); - sent_paths(&req.params, &mut sent); - hiding.set_hidden(sent, cfg!(windows)); + let mut paths = Vec::new(); + sent_paths(sent, &mut paths); + hiding.set_hidden(paths, cfg!(windows)); + } + hide_library_ids(reply, &hiding); +} + +/// Hides in a reply sent after its request was handled (a screenshot's) what [`handle`] hides in a +/// reply, with `sent` as the request's params. Without a read root in force it leaves the reply as +/// it is. +pub(crate) fn hide_in_deferred_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value) { + if vectorcraft_engine::file_access::current().is_some() { + hide_in_reply(app, reply, sent, Hiding::default()); } - hide_library_ids(&mut reply, &hiding); - Outcome::Done(reply) } /// The control methods that send synthetic input. diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index efa2fce9d0..68717c3f8d 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -752,6 +752,26 @@ fn an_error_hides_a_sent_path_whether_or_not_a_library_was_loaded_from_it() { let _ = std::fs::remove_dir_all(base); } +/// A screenshot's reply, sent after its request was handled, hides a hidden library's path, and in an +/// error the path outside the read root that the request sent. +#[test] +fn a_screenshot_reply_hides_paths_like_any_reply() { + let (mut app, _tx, base) = confined("deferred-reply"); + let path = s(base.join("outside/b.svg")); + app.ui.color_guide_limit = format!("loaded/{path}"); + let sent = s(base.join("outside/shot.png")); + let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap(); + let mut done = json!({"ok": true, "result": {"note": format!("from {path}")}}); + let mut failed = json!({"ok": false, "error": format!("could not write {sent}")}); + vectorcraft_engine::file_access::confine(roots.as_ref(), || { + crate::control::hide_in_deferred_reply(&app, &mut done, &json!({"path": sent})); + crate::control::hide_in_deferred_reply(&app, &mut failed, &json!({"path": sent})); + }); + assert_eq!(done["result"]["note"], "from b.svg", "{done}"); + assert_eq!(failed["error"], "could not write shot.png", "{failed}"); + let _ = std::fs::remove_dir_all(base); +} + /// A reply whose only trace of a hidden library's path is a `file:` URL with every separator escaped /// shows the file name. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index d53ee2289d..b4cfad7e5f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -166,8 +166,8 @@ the suite) keep the agent to the folders you give it: reopens its panel), and the reply to such a request shows that id and file name wherever the library's id or path appears, an error included. Every reply under the read roots shows a hidden library's id as the id shown and its path as the file name in every string and key, the status text (`ui.status`), dialogs, the command palette query - (`ui.palette_query`), a script's report (`scriptReport`) and the error from an internal fault included, for the - libraries hidden before the request ran and after it. The path is also matched with `%XX` escapes as a `file:` + (`ui.palette_query`), a script's report (`scriptReport`), a screenshot's reply and the error from an internal + fault included, for the libraries hidden before the request ran and after it. The path is also matched with `%XX` escapes as a `file:` URL writes it, and on Windows in any case of its letters. A hidden id or path stays only where it begins another loaded library's whole id with no `.` or `..` part, so a readable library's id that begins with a hidden one stays. The error of a failed request also shows a path outside the read roots that the request sent by its file name, whether or not a library was loaded from it, so that the From f6cabe62cc0e2435ad9d7b15dd81f26745ca96b0 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 17:31:25 -0800 Subject: [PATCH 33/92] MCP: a readable library id written with LOADED/ keeps its text on Windows The loaded/ prefix is matched in any case where the ids and paths are. --- crates/ui-egui/src/control.rs | 3 ++- crates/ui-egui/src/control/tests.rs | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index ee24d563bf..abe602b6d7 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -694,8 +694,9 @@ fn begins_known_id(text: &str, at: usize, end: usize, r: &Rewrite) -> bool { let rest = &text[at..]; let same = |a: &str, b: &str| if r.fold { a.eq_ignore_ascii_case(b) } else { a == b }; let dotted = |id: &str| id.split(['/', '\\']).any(|part| part == "." || part == ".."); + let prefixed = rest.get(.."loaded/".len()).is_some_and(|head| same(head, "loaded/")); r.hiding.readable.iter().filter(|known| !dotted(known)).any(|known| { - let id = if rest.starts_with("loaded/") { known.as_str() } else { known.strip_prefix("loaded/").unwrap_or(known) }; + let id = if prefixed { known.as_str() } else { known.strip_prefix("loaded/").unwrap_or(known) }; id.len() > end - at && rest.get(..id.len()).is_some_and(|head| same(head, id)) && !continues_path(&rest[id.len()..]) }) } diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 026b51bed6..621fa6ab50 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -105,6 +105,11 @@ fn a_reply_hides_a_path_in_any_case_and_escaped() { let mut reply = json!({"error": "x /libs/B.svg y /libs/b.svg z /LIBS/b.svg"}); super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&two.hidden), hiding: &two, fold: true }); assert_eq!(reply["error"], "x B.svg y b.svg z b.svg", "{reply}"); + // A readable id that begins with a hidden one stays whole in any case, `loaded/` included. + let nested = hiding(&[("loaded//libs/in", "loaded/in")], &["loaded//libs/in/a.svg"]); + let mut reply = json!({"error": "LOADED//LIBS/IN/A.SVG"}); + super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&nested.hidden), hiding: &nested, fold: true }); + assert_eq!(reply["error"], "LOADED//LIBS/IN/A.SVG", "{reply}"); let mut reply = json!({"error": "file:///C:/Libs/My%20Swatches/b.svg"}); hide_under_a_root("escaped", &mut reply, &hidden); assert_eq!(reply["error"], "file:///b.svg", "{reply}"); From 62be2df0585a251acd7278af5d9ddba422da4a6c Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 18:50:00 -0800 Subject: [PATCH 34/92] MCP: replies hide each sent path the same way whatever the outcome Each hidden id or path is replaced once, chosen from the reply as given, so a hidden path inside another library's shown id is not replaced again, and a readable id written with %XX escapes keeps the hidden path it begins with. A path quoted with {:?} is matched with its quotes and control characters escaped. A path outside the read roots that the request sent is shown by its file name in a reply that succeeded as in one that failed, and a path param counts with a relative path too. A relative path is not replaced where it ends a longer path. Without a read root, a file: URL that names another host stays in ui.inspect's recorded steps. --- crates/ui-egui/src/control.rs | 216 +++++++++++++------------ crates/ui-egui/src/control/tests.rs | 83 +++++++++- crates/ui-egui/src/tests/automation.rs | 5 +- docs/mcp.md | 8 +- 4 files changed, 200 insertions(+), 112 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index abe602b6d7..40ec47f7f5 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -401,13 +401,19 @@ impl Hiding { } /// The paths outside the read roots that `v`'s strings and keys name at any depth, written as paths -/// ([`unreadable_path_like`]), each as a hidden library id (`loaded/<path>`) with `loaded/<file name>` -/// shown. The error of a failed request hides them as it hides a hidden library's path, with that file -/// name also for a hidden library shown as `loaded/<file name> (2)`, so that it reads the same whether -/// or not a library was loaded from one of them. +/// ([`unreadable_path_like`]) or held by a path param +/// ([`vectorcraft_engine::file_access::is_path_key`]), a relative one too, each as a hidden library id +/// (`loaded/<path>`) with `loaded/<file name>` shown. A reply hides them as it hides a hidden library's +/// path, with that file name also for a hidden library shown as `loaded/<file name> (2)`, so that it +/// reads the same whether or not a library was loaded from one of them. fn sent_paths(v: &Value, out: &mut Vec<(String, String)>) { - fn add(s: &str, out: &mut Vec<(String, String)>) { - if !unreadable_path_like(s) { + collect_sent_paths(v, false, out); +} + +/// [`sent_paths`] on `v`, which a path param holds when `path_param` is set. +fn collect_sent_paths(v: &Value, path_param: bool, out: &mut Vec<(String, String)>) { + fn add(s: &str, path_param: bool, out: &mut Vec<(String, String)>) { + if !unreadable_path_like(s) && !(path_param && !readable_value(s)) { return; } // A URL that names another host as the path after its scheme (`//host/share/…`). @@ -423,12 +429,12 @@ fn sent_paths(v: &Value, out: &mut Vec<(String, String)>) { } } match v { - Value::String(s) => add(s, out), - Value::Array(items) => items.iter().for_each(|item| sent_paths(item, out)), + Value::String(s) => add(s, path_param, out), + Value::Array(items) => items.iter().for_each(|item| collect_sent_paths(item, path_param, out)), Value::Object(fields) => { for (key, item) in fields { - add(key, out); - sent_paths(item, out); + add(key, false, out); + collect_sent_paths(item, vectorcraft_engine::file_access::is_path_key(key), out); } } _ => {} @@ -470,7 +476,7 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet<Str /// ([`begins_known_id`]): a readable library's id that begins with a hidden one keeps its path. On /// Windows the ids and paths are matched in any case of ASCII letters. fn hide_library_ids(v: &mut Value, hiding: &Hiding) { - let pairs = hidden_text(&hiding.hidden); + let pairs = hidden_text(hiding); if pairs.is_empty() { return; } @@ -478,16 +484,22 @@ fn hide_library_ids(v: &mut Value, hiding: &Hiding) { } /// The text [`hide_library_ids`] replaces, longest first: each hidden id with the id shown, and the -/// path in it with the file name shown, the path written with either separator too, and with each `\` -/// doubled (a path quoted with `{:?}`). -fn hidden_text(ids: &[(String, String)]) -> Vec<(String, String)> { +/// path in it with the file name shown, the path written with either separator too, and as `{:?}` +/// quotes it (each `\`, quote and control character escaped). +fn hidden_text(hiding: &Hiding) -> Vec<(String, String)> { + let quoted = |s: &str| { + let q = format!("{s:?}"); + q[1..q.len() - 1].to_string() + }; let mut pairs: Vec<(String, String)> = Vec::new(); - for (id, shown) in ids { + for (id, shown) in &hiding.hidden { let path = &id["loaded/".len()..]; let name = shown.strip_prefix("loaded/").unwrap_or(shown); - let back = path.replace('/', "\\"); - for path in [path.to_string(), path.replace('\\', "/"), back.replace('\\', "\\\\"), back] { - for (from, to) in [(format!("loaded/{path}"), shown.clone()), (path, name.to_string())] { + let (forward, back) = (path.replace('\\', "/"), path.replace('/', "\\")); + let debug = [quoted(path), quoted(&forward), quoted(&back)].map(|p| (p, quoted(name))); + let plain = [path.to_string(), forward, back].map(|p| (p, name.to_string())); + for (path, name) in plain.into_iter().chain(debug) { + for (from, to) in [(format!("loaded/{path}"), format!("loaded/{name}")), (path, name)] { if !pairs.iter().any(|(f, _)| *f == from) { pairs.push((from, to)); } @@ -556,46 +568,70 @@ fn occurrences(text: &str, needle: &str, fold: bool) -> Vec<usize> { /// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, at each occurrence /// that does not begin another loaded library's id ([`begins_known_id`]). A hidden id or path written -/// with `%XX` escapes (in a `file:` URL) is -/// replaced too; the escapes around it stay. `None` when nothing changed. +/// with `%XX` escapes (in a `file:` URL) is replaced too; the escapes around it stay. Every occurrence +/// is found in `s` as it was given, longest `from` first, and one that overlaps an occurrence already +/// found, or a loaded library's id that one began, stays: a replacement's text is not searched again. +/// With the rewrite's `fold`, the ids and paths as written are found first, so that two libraries whose +/// paths differ only in letter case keep their own ids, and then the ones written in another case. +/// `None` when nothing changed. fn replace_text(s: &str, r: &Rewrite) -> Option<String> { - let replaced = replace_plain_text(s, r); - let text = replaced.as_deref().unwrap_or(s); - if !text.contains('%') { - return replaced; - } - let Some((decoded, spans)) = decoded_with_spans(text) else { return replaced }; - if decoded == text { - return replaced; - } - // The spans of `text` to replace, found in the decoded text, longest `from` first. - let mut cuts: Vec<(usize, usize, &str)> = Vec::new(); - let mut taken: Vec<(usize, usize)> = Vec::new(); - for (from, to) in &r.pairs { - for at in occurrences(&decoded, from, r.fold) { - let end = at + from.len(); - let free = !taken.iter().any(|&(a, b)| at < b && a < end); - if free && !begins_known_id(&decoded, at, end, r) { - taken.push((at, end)); - cuts.push((spans[at].0, spans[end - 1].1, to.as_str())); + let decoded = if s.contains('%') { decoded_with_spans(s).filter(|(decoded, _)| decoded != s) } else { None }; + let mut found = Found::default(); + for fold in [false, true].into_iter().take(if r.fold { 2 } else { 1 }) { + for (from, to) in &r.pairs { + if let Some((text, spans)) = &decoded { + found.claim(text, Some(spans.as_slice()), from, to, fold, r); } + found.claim(s, None, from, to, fold, r); } } - if cuts.is_empty() { - return replaced; + if found.cuts.is_empty() { + return None; } + let mut cuts = found.cuts; cuts.sort_unstable_by_key(|&(start, _, _)| start); - let mut out = String::with_capacity(text.len()); + let mut out = String::with_capacity(s.len()); let mut copied = 0; for (start, stop, to) in cuts { - out.push_str(&text[copied..start]); + out.push_str(&s[copied..start]); out.push_str(to); copied = stop; } - out.push_str(&text[copied..]); + out.push_str(&s[copied..]); Some(out) } +/// The spans of a text that [`replace_text`] replaces, each with its replacement, and every span it has +/// taken: the ones it replaces and the loaded library ids it keeps. +#[derive(Default)] +struct Found<'a> { + cuts: Vec<(usize, usize, &'a str)>, + taken: Vec<(usize, usize)>, +} + +impl<'a> Found<'a> { + /// Takes each occurrence of `from` in `text` that overlaps no span taken: as a span to write as `to`, + /// or, where it begins a loaded library's id ([`begins_known_id`]), that id's span, kept. `spans` + /// gives the range of the original text each byte of a decoded `text` was read from. + fn claim(&mut self, text: &str, spans: Option<&[(usize, usize)]>, from: &str, to: &'a str, fold: bool, r: &Rewrite) { + let span = |at: usize, end: usize| spans.map_or((at, end), |s| (s[at].0, s[end - 1].1)); + for at in occurrences(text, from, fold) { + let end = at + from.len(); + let (start, stop) = span(at, end); + if self.taken.iter().any(|&(a, b)| start < b && a < stop) || ends_a_longer_path(text, at, from) { + continue; + } + match begins_known_id(text, at, end, r) { + Some(len) => self.taken.push(span(at, at + len)), + None => { + self.taken.push((start, stop)); + self.cuts.push((start, stop, to)); + } + } + } + } +} + /// `text` with its `%XX` escapes decoded, and for each byte of the decoded text the range of `text` /// it was read from. A decoded byte that is not part of UTF-8 text is read as U+FFFD. fn decoded_with_spans(text: &str) -> Option<(String, Vec<(usize, usize)>)> { @@ -639,44 +675,18 @@ fn decoded_with_spans(text: &str) -> Option<(String, Vec<(usize, usize)>)> { Some((decoded, decoded_spans)) } -/// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, as [`replace_text`] -/// writes them in text without `%XX` escapes. With the rewrite's `fold`, the ids and paths as written -/// are replaced first, so that two libraries whose paths differ only in letter case keep their own -/// ids, and then the ones written in another case. `None` when nothing changed. -fn replace_plain_text(s: &str, r: &Rewrite) -> Option<String> { - let exact = replace_matches(s, r, false); - if !r.fold { - return exact; - } - replace_matches(exact.as_deref().unwrap_or(s), r, true).or(exact) -} - -/// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, matched in any case -/// of ASCII letters with `fold`. `None` when nothing changed. -fn replace_matches(s: &str, r: &Rewrite, fold: bool) -> Option<String> { - let mut out: Option<String> = None; - for (from, to) in &r.pairs { - let text = out.as_deref().unwrap_or(s); - let found = occurrences(text, from, fold); - if found.is_empty() { - continue; - } - let mut next = String::with_capacity(text.len()); - let mut copied = 0; - for at in found { - let end = at + from.len(); - if !begins_known_id(text, at, end, r) { - next.push_str(&text[copied..at]); - next.push_str(to); - copied = end; - } - } - if copied > 0 { - next.push_str(&text[copied..]); - out = Some(next); - } - } - out +/// Is `from`, a relative path (one that does not start with a separator, a drive or `loaded/`), at +/// `text[at..]` the end of a longer path: is it preceded by a letter, a digit, `_`, `-`, `.`, `~` or a +/// separator other than one after `.` or `..`? `/work/data/icon.svg` does not hold the path +/// `data/icon.svg`; `./data/icon.svg` does. +fn ends_a_longer_path(text: &str, at: usize, from: &str) -> bool { + let absolute = + from.starts_with(['/', '\\']) || from.starts_with("loaded/") || matches!(from.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()); + let before = &text[..at]; + !absolute + && !before.ends_with("./") + && !before.ends_with(".\\") + && before.ends_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '~' | '/' | '\\')) } /// Does text that follows a path continue it: a letter, a digit, `_` or a separator, after any `.`, @@ -687,17 +697,18 @@ fn continues_path(rest: &str) -> bool { /// Does the occurrence `text[at..end]` begin a longer loaded library id ([`Hiding::readable`]), written /// with or without its `loaded/` and with no `.` or `..` part, that ends where the text stops -/// continuing a path ([`continues_path`])? +/// continuing a path ([`continues_path`])? The length of that id as written, when it does. /// A readable library's id that begins with a hidden one stays, also with a space after the hidden /// part; a hidden path followed by other path text (`<path>.bak`, `<path>/../x`) is still hidden. -fn begins_known_id(text: &str, at: usize, end: usize, r: &Rewrite) -> bool { +fn begins_known_id(text: &str, at: usize, end: usize, r: &Rewrite) -> Option<usize> { let rest = &text[at..]; let same = |a: &str, b: &str| if r.fold { a.eq_ignore_ascii_case(b) } else { a == b }; let dotted = |id: &str| id.split(['/', '\\']).any(|part| part == "." || part == ".."); let prefixed = rest.get(.."loaded/".len()).is_some_and(|head| same(head, "loaded/")); - r.hiding.readable.iter().filter(|known| !dotted(known)).any(|known| { + r.hiding.readable.iter().filter(|known| !dotted(known)).find_map(|known| { let id = if prefixed { known.as_str() } else { known.strip_prefix("loaded/").unwrap_or(known) }; - id.len() > end - at && rest.get(..id.len()).is_some_and(|head| same(head, id)) && !continues_path(&rest[id.len()..]) + let begins = id.len() > end - at && rest.get(..id.len()).is_some_and(|head| same(head, id)) && !continues_path(&rest[id.len()..]); + begins.then_some(id.len()) }) } @@ -786,11 +797,11 @@ fn readable(path: &str) -> bool { } /// Is `s` a path the request may not read, written as an absolute path (either separator, with or -/// without a drive) or a `file:` URL ([`file_url_path`])? A `file:` URL that names another host is -/// never readable. +/// without a drive) or a `file:` URL ([`file_url_path`])? Under a read root, a `file:` URL that names +/// another host is never readable. fn unreadable_absolute(s: &str) -> bool { match file_url_path(s) { - Some(path) => !path.is_some_and(|p| readable(&p)), + Some(path) => !readable_url_path(path), None => { let absolute = s.starts_with(['/', '\\']) || matches!(s.as_bytes(), [drive, b':', b'/' | b'\\', ..] if drive.is_ascii_alphabetic()); absolute && !readable(s) @@ -814,11 +825,20 @@ fn unreadable_path_like(s: &str) -> bool { /// May the request read the path `s` names, a path or a `file:` URL ([`file_url_path`])? fn readable_value(s: &str) -> bool { match file_url_path(s) { - Some(path) => path.is_some_and(|p| readable(&p)), + Some(path) => readable_url_path(path), None => readable(s), } } +/// May the request read the local path a `file:` URL names ([`file_url_path`])? A URL that names +/// another host may be read only without a read root in force. +fn readable_url_path(path: Option<String>) -> bool { + match path { + Some(path) => readable(&path), + None => vectorcraft_engine::file_access::current().is_none(), + } +} + /// The local path that `s` names when it is a `file:` URL, with the scheme in any case and its `%XX` /// escapes decoded: `Some(None)` for a URL that names another host (`file://host/share`). /// `file:///C:/x` names `C:/x`. @@ -1001,8 +1021,8 @@ const DOCKING_ACTION: &str = "action"; /// in a [`LIBRARY_PARAMS`] param, and the reply shows that id. Under a read root, the reply shows each /// such library's id and path as `ui.inspect` does ([`hide_library_ids`]) in every string and key, /// errors included, an error from a panic too: the libraries hidden before it ran and after it. The -/// error of a failed request also hides the paths outside the read root that the request sent -/// ([`sent_paths`]). A reply with no `/`, `\` or `%` in it is not searched, since every hidden path +/// reply also shows each path outside the read root that the request sent by its file name, whether it +/// succeeded or failed ([`sent_paths`]). A reply with no `/`, `\` or `%` in it is not searched, since every hidden path /// holds a separator, written or escaped. A screenshot's reply is hidden when it is sent /// ([`hide_in_deferred_reply`]); a `ui.pointer` reply sent after the tool's edit holds no library id /// or path. @@ -1037,19 +1057,17 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques Outcome::Done(reply) } -/// Hides in `reply` the libraries hidden now and in `before`, and for a failed request the paths -/// outside the read root that `sent`, the request's params, holds ([`handle`]). +/// Hides in `reply` the libraries hidden now and in `before`, and the paths outside the read root that +/// `sent`, the request's params, holds ([`handle`]). fn hide_in_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value, before: Hiding) { if !holds_a_separator(reply) { return; } let mut hiding = unreadable_library_ids(app); hiding.merge(before); - if reply.get("ok").and_then(Value::as_bool) == Some(false) { - let mut paths = Vec::new(); - sent_paths(sent, &mut paths); - hiding.set_hidden(paths, cfg!(windows)); - } + let mut paths = Vec::new(); + sent_paths(sent, &mut paths); + hiding.set_hidden(paths, cfg!(windows)); hide_library_ids(reply, &hiding); } diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 621fa6ab50..3d2a68b2be 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -46,7 +46,7 @@ fn hide_under_a_root(tag: &str, reply: &mut Value, hidden: &super::Hiding) { #[test] fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() { let hidden = hiding(&[("loaded/assets/icon", "loaded/icon")], &[]); - let data = "QUJDassets/iconREVGxQQ=="; + let data = "QUJ+assets/iconREVGxQQ=="; let mut reply = json!({"ok": true, "result": { "files": [{"name": "a.png", "dataBase64": data, "pngBase64": "assets/icon.png", "thumbnailBase64": data}], "status": "from assets/icon", @@ -61,7 +61,7 @@ fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() { assert_eq!(reply["result"]["status"], "from icon", "{reply}"); assert_eq!(reply["result"]["dataBase64"]["note"], "from icon", "{reply}"); assert_eq!(reply["result"]["pngBase64"][0], "from icon", "{reply}"); - assert_eq!(reply["result"]["otherBase64"], "QUJDiconREVGxQQ==", "{reply}"); + assert_eq!(reply["result"]["otherBase64"], "QUJ+iconREVGxQQ==", "{reply}"); } /// A hidden path followed by more text stays only when the longer text is another loaded library's @@ -96,19 +96,19 @@ fn a_reply_keeps_a_hidden_path_only_inside_a_known_library_id() { fn a_reply_hides_a_path_in_any_case_and_escaped() { let hidden = hiding(&[("loaded/C:/Libs/My Swatches/b.svg", "loaded/b.svg")], &[]); let mut reply = json!({"error": "from c:/LIBS/my swatches/B.SVG and file:///C:/Libs/My%20Swatches/b.svg"}); - let pairs = super::hidden_text(&hidden.hidden); + let pairs = super::hidden_text(&hidden); let rewrite = super::Rewrite { pairs, hiding: &hidden, fold: true }; super::replace_in_value(&mut reply, &rewrite); assert_eq!(reply["error"], "from b.svg and file:///b.svg", "{reply}"); // Two libraries whose paths differ only in letter case keep their own ids. let two = hiding(&[("loaded//libs/b.svg", "loaded/b.svg"), ("loaded//libs/B.svg", "loaded/B.svg")], &[]); let mut reply = json!({"error": "x /libs/B.svg y /libs/b.svg z /LIBS/b.svg"}); - super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&two.hidden), hiding: &two, fold: true }); + super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&two), hiding: &two, fold: true }); assert_eq!(reply["error"], "x B.svg y b.svg z b.svg", "{reply}"); // A readable id that begins with a hidden one stays whole in any case, `loaded/` included. let nested = hiding(&[("loaded//libs/in", "loaded/in")], &["loaded//libs/in/a.svg"]); let mut reply = json!({"error": "LOADED//LIBS/IN/A.SVG"}); - super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&nested.hidden), hiding: &nested, fold: true }); + super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&nested), hiding: &nested, fold: true }); assert_eq!(reply["error"], "LOADED//LIBS/IN/A.SVG", "{reply}"); let mut reply = json!({"error": "file:///C:/Libs/My%20Swatches/b.svg"}); hide_under_a_root("escaped", &mut reply, &hidden); @@ -132,8 +132,8 @@ fn a_sent_path_replaces_a_hidden_id_for_the_same_path() { } /// The paths outside the read root that a request sends, as a path, a `file:` URL (also one that names -/// another host), under `~` or starting with `../`, are hidden by their file names; plain text and a -/// path inside the root are not. +/// another host), under `~` or starting with `../`, or as a relative path in a path param, are hidden +/// by their file names; plain text and a path inside the root are not. #[test] fn a_request_sends_paths_that_an_error_hides() { let base = std::env::temp_dir().join(format!("vc-sent-paths-{}", std::process::id())); @@ -141,13 +141,14 @@ fn a_request_sends_paths_that_an_error_hides() { std::fs::create_dir_all(&root).unwrap(); let inside = root.join("a.svg").to_string_lossy().into_owned(); let keep = json!(["a/b", inside, "file://host/g.svg"]); - let params = json!({"path": "/x/my lib/b.svg", "list": ["file:///y/c%20d.svg", "~/e.svg", "../f.svg"], "other": keep}); + let params = json!({"path": "/x/my lib/b.svg", "file": "outside/h.svg", "list": ["file:///y/c%20d.svg", "~/e.svg", "../f.svg"], "other": keep}); let mut sent = Vec::new(); let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(¶ms, &mut sent)); let _ = std::fs::remove_dir_all(base); let pair = |path: &str, name: &str| (format!("loaded/{path}"), format!("loaded/{name}")); let want = [ + pair("outside/h.svg", "h.svg"), pair("/y/c d.svg", "c d.svg"), pair("~/e.svg", "e.svg"), pair("../f.svg", "f.svg"), @@ -183,6 +184,72 @@ fn an_absolute_path_outside_the_read_root_is_unreadable() { let _ = std::fs::remove_dir_all(base); } +/// Without a read root, a `file:` URL that names another host may be read, and a recorded step keeps +/// it. +#[test] +fn a_url_naming_another_host_stays_without_a_read_root() { + assert!(!super::unreadable_absolute("file://host/share/a.svg")); + assert!(super::readable_value("file://host/share/a.svg")); + let mut params = json!({"path": "file://host/share/a.svg", "list": ["file://host/share/b.svg"]}); + let fields = params.as_object_mut().unwrap(); + super::leave_out_unreadable_params(fields); + assert_eq!(params, json!({"path": "file://host/share/a.svg", "list": ["file://host/share/b.svg"]})); +} + +/// A hidden path quoted with `{:?}` is hidden with its quotes and control characters escaped. +#[test] +fn a_reply_hides_a_quoted_path_with_escaped_quotes() { + let hidden = hiding(&[("loaded//outside/a\"b\t.svg", "loaded/a\"b\t.svg")], &[]); + let mut reply = json!({"error": format!("can't read {:?}", "/outside/a\"b\t.svg")}); + hide_under_a_root("quoted", &mut reply, &hidden); + assert_eq!(reply["error"], "can't read \"a\\\"b\\t.svg\"", "{reply}"); +} + +/// Each replacement is chosen from the text as given: a hidden path inside the id shown for another +/// hidden library is not replaced again, and a readable id written with `%XX` escapes keeps the hidden +/// path it begins with. +#[test] +fn a_reply_replaces_each_hidden_text_once() { + let hidden = hiding(&[("loaded//b.svg", "loaded/b.svg")], &[]); + let mut reply = json!({"error": "no loaded//b.svg"}); + hide_under_a_root("root-file", &mut reply, &hidden); + assert_eq!(reply["error"], "no loaded/b.svg", "{reply}"); + let hidden = hiding(&[("loaded//art/in", "loaded/in")], &["loaded//art/in work/a.svg"]); + let mut reply = json!({"error": "file:///art/in%20work/a.svg"}); + hide_under_a_root("escaped-readable", &mut reply, &hidden); + assert_eq!(reply["error"], "file:///art/in%20work/a.svg", "{reply}"); +} + +/// A hidden relative path is hidden where it stands alone or after `./`, and stays where it ends a +/// longer path. +#[test] +fn a_reply_keeps_a_longer_path_that_ends_with_a_hidden_relative_path() { + let hidden = hiding(&[("loaded/data/icon.svg", "loaded/icon.svg")], &[]); + let mut reply = json!({"result": "kept /work/data/icon.svg and xdata/icon.svg, hid ./data/icon.svg, \"data/icon.svg\" and loaded/data/icon.svg"}); + hide_under_a_root("relative", &mut reply, &hidden); + assert_eq!(reply["result"], "kept /work/data/icon.svg and xdata/icon.svg, hid ./icon.svg, \"icon.svg\" and loaded/icon.svg", "{reply}"); +} + +/// A path outside the read root that a request sent is shown by its file name in a reply that succeeded +/// as in one that failed. +#[test] +fn a_sent_path_reads_the_same_whatever_the_outcome() { + let app = VectorcraftApp::new(Session::new(), Services::default()); + let root = std::env::temp_dir().join(format!("vc-sent-outcome-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + let sent = json!({"path": "/outside/b.svg"}); + let mut done = json!({"ok": true, "result": {"path": "/outside/b.svg"}}); + let mut failed = json!({"ok": false, "error": "can't read /outside/b.svg"}); + vectorcraft_engine::file_access::confine(roots.as_ref(), || { + super::hide_in_reply(&app, &mut done, &sent, super::Hiding::default()); + super::hide_in_reply(&app, &mut failed, &sent, super::Hiding::default()); + }); + let _ = std::fs::remove_dir_all(root); + assert_eq!(done["result"]["path"], "b.svg", "{done}"); + assert_eq!(failed["error"], "can't read b.svg", "{failed}"); +} + #[test] fn deferred_pointer_reply_uses_the_gesture_document_after_file_new() { let mut app = VectorcraftApp::new(Session::new(), Services::default()); diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 68717c3f8d..57d63e9f69 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -729,7 +729,8 @@ fn a_panic_reply_hides_a_library_hidden_before_the_request() { /// An error shows a path outside the read root that the request sent by its file name, whether or not /// a hidden library was loaded from that path, so that it reads the same either way. A path the -/// request sends does not show the app's own text that holds it, in an error or a result. +/// request sends does not show the app's own text that holds it, in an error or a result, and a result +/// shows that path by its file name as an error does. #[test] fn an_error_hides_a_sent_path_whether_or_not_a_library_was_loaded_from_it() { let (mut app, tx, base) = confined("client-echo"); @@ -746,7 +747,7 @@ fn an_error_hides_a_sent_path_whether_or_not_a_library_was_loaded_from_it() { assert!(r["error"].as_str().is_some_and(|e| e.ends_with(&format!(": {name}"))), "{r}"); } let r = request(&mut app, &tx, "test.status", json!({"guess": path})); - assert_eq!(r["result"], "Loaded b.svg (2)", "{r}"); + assert_eq!(r["result"], "Loaded b.svg", "{r}"); let r = request(&mut app, &tx, "test.panic", json!({"guess": path})); assert_eq!(r["error"], "internal error: failed on Loaded b.svg (please report this bug)", "{r}"); let _ = std::fs::remove_dir_all(base); diff --git a/docs/mcp.md b/docs/mcp.md index b4cfad7e5f..a3c47ec71d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -169,9 +169,11 @@ the suite) keep the agent to the folders you give it: (`ui.palette_query`), a script's report (`scriptReport`), a screenshot's reply and the error from an internal fault included, for the libraries hidden before the request ran and after it. The path is also matched with `%XX` escapes as a `file:` URL writes it, and on Windows in any case of its letters. A hidden id or path stays only where it begins - another loaded library's whole id with no `.` or `..` part, so a readable library's id that begins with a hidden one stays. The error of a failed request also shows a path outside the - read roots that the request sent by its file name, whether or not a library was loaded from it, so that the - error reads the same either way. Encoded file data, a base64 string under `dataBase64`, + another loaded library's whole id with no `.` or `..` part, so a readable library's id that begins with a hidden one stays. A reply also shows a path outside the + read roots that the request sent by its file name, whether or not a library was loaded from it and whether the + request succeeded or failed, so that the reply reads the same either way. A path param (`path`, `file`, + `destinationFolder` and the like) counts with a relative path too. A relative path is not replaced where it ends a + longer path (`/work/data/icon.svg` keeps `data/icon.svg`). Encoded file data, a base64 string under `dataBase64`, `pngBase64` or `thumbnailBase64`, is sent as it is. - `swatch.library.list`, `brush.libraries`, `symbol.libraries` and `graphicStyle.libraries` list every library but leave out the paths outside the read roots: a library's `path` and the user library folder in `userFolder` From d1e766173e7216346b3dbcd3935df55d4c7c13a4 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 18:59:31 -0800 Subject: [PATCH 35/92] MCP: a library saved with another platform's absolute path is hidden under a read root Outside Windows, a library path that starts with a drive or a backslash counts as outside the read roots, since the file access check would read it as a name in the working folder. --- crates/engine/src/cmd/swatchlib.rs | 19 ++++++-- crates/ui-egui/src/control/tests.rs | 12 +++++ crates/ui-egui/src/tests/automation.rs | 64 +++++++++++++------------- docs/mcp.md | 3 +- 4 files changed, 62 insertions(+), 36 deletions(-) diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 45320066cf..bc48f9d323 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -475,9 +475,18 @@ fn readable(path: &str) -> bool { crate::file_access::check_read(path).is_ok() } +/// Is `path`, under a read root, written as an absolute path of another platform: outside Windows, one +/// that starts with a drive (`C:`) or a `\`? The file access check would read it as a name in the +/// working folder. +fn written_elsewhere(path: &str) -> bool { + let foreign = path.starts_with('\\') || matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()); + !cfg!(windows) && foreign && crate::file_access::current().is_some() +} + /// The loaded library ids (`loaded/<path>`) whose file the request being served may not read, each /// with the id a reply shows instead: `loaded/<file name>`, as a library loaded without a path is -/// named. The ids are those of the session's swatch, brush, symbol and graphic style libraries, then +/// named. The file name is the last part of the path after either separator, and a path saved as +/// another platform's absolute path counts as one the request may not read. The ids are those of the session's swatch, brush, symbol and graphic style libraries, then /// `others` (ids the UI state refers to). When another id already is or shows that id, the next free /// one of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two. The /// session's libraries are numbered first, in the sorted order of their full ids, so a library shows @@ -493,9 +502,13 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = Strin let mut taken = BTreeSet::new(); for ids in [session, others] { for id in ids { - let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| (file_name(path), path)); + let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| { + // Either separator: a path saved on another platform counts as a path here too. + let name = path.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(path).to_string(); + (name, path) + }); match name { - Some((name, path)) if name != path && !readable(path) => hidden.push((id, name)), + Some((name, path)) if name != path && (written_elsewhere(path) || !readable(path)) => hidden.push((id, name)), _ => { taken.insert(id); } diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 3d2a68b2be..13890c28bb 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -220,6 +220,18 @@ fn a_reply_replaces_each_hidden_text_once() { assert_eq!(reply["error"], "file:///art/in%20work/a.svg", "{reply}"); } +/// A library saved with another platform's absolute path is hidden, also when the working folder is +/// inside the read root. +#[test] +fn a_library_saved_with_another_platforms_path_is_hidden() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.ui.color_guide_limit = r"loaded/C:\Users\someone\b.svg".to_string(); + let here = std::env::current_dir().unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&here], &[]).unwrap(); + let hiding = vectorcraft_engine::file_access::confine(roots.as_ref(), || super::unreadable_library_ids(&app)); + assert_eq!(hiding.hidden, [(r"loaded/C:\Users\someone\b.svg".to_string(), "loaded/b.svg".to_string())], "{hiding:?}"); +} + /// A hidden relative path is hidden where it stands alone or after `./`, and stays where it ends a /// longer path. #[test] diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 57d63e9f69..e36388bf31 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -446,33 +446,6 @@ fn ui_inspect_leaves_out_paths_outside_the_read_root() { let _ = std::fs::remove_dir_all(base); } -/// Two libraries loaded from outside the read root under the same file name, only the second of them -/// in a library panel: `ui.inspect` and `swatch.library.list` show the second under the same id, and -/// that id finds it. -#[test] -fn ui_inspect_and_library_commands_show_a_hidden_library_under_one_id() { - let (mut app, tx, base) = confined("one-id"); - let (x, y) = (base.join("outside/x/Kit.gpl"), base.join("outside/y/Kit.gpl")); - for (path, colors) in [(&x, "0 0 0 Black\n"), (&y, "0 0 0 Black\n255 255 255 White\n")] { - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(path, format!("GIMP Palette\nName: Kit\n{colors}")).unwrap(); - app.session.execute("swatch.library.load", &json!({"path": s(path.clone())})).unwrap(); - } - let second = format!("loaded/{}", s(y.clone())); - app.ui.library_panels = vec![serde_json::from_value(json!({"kind": "swatches", "id": second, "path": s(y.clone())})).unwrap()]; - - let r = request(&mut app, &tx, "ui.inspect", json!({})); - assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/Kit.gpl (2)", "{r}"); - let r = request(&mut app, &tx, "engine.execute", json!({"command": "swatch.library.list", "params": {}})); - let libs = r["result"]["libraries"].as_array().unwrap_or_else(|| panic!("{r}")); - let loaded: Vec<&Value> = libs.iter().filter(|l| l["category"] == "loaded").map(|l| &l["id"]).collect(); - assert_eq!(loaded, [&json!("loaded/Kit.gpl"), &json!("loaded/Kit.gpl (2)")], "{r}"); - let r = request(&mut app, &tx, "engine.execute", json!({"command": "swatch.library.get", "params": {"library": "loaded/Kit.gpl (2)"}})); - assert_eq!(r["result"]["swatches"].as_array().map(Vec::len), Some(2), "the second library: {r}"); - assert!(!r.to_string().contains("outside"), "{r}"); - let _ = std::fs::remove_dir_all(base); -} - /// `ui.inspect` checks a folder preference as it is written: the read root's path with a space before /// it is a relative path, outside the root, and is left out. #[test] @@ -675,10 +648,10 @@ fn ui_inspect_keeps_a_hidden_library_off_a_loaded_librarys_id() { let _ = std::fs::remove_dir_all(base); } -/// Hidden libraries with the same file name are numbered in the order they were loaded, not in the -/// order of their paths. +/// Hidden libraries with the same file name that are shown for the first time in the same reply are +/// numbered in the order of their paths, as the library commands number them. #[test] -fn hidden_libraries_are_numbered_in_load_order() { +fn hidden_libraries_are_numbered_in_the_order_of_their_paths() { let (mut app, tx, base) = confined("hidden-load-order"); let gpl = "GIMP Palette\nName: p\n0 0 0 Black\n"; for dir in ["z", "a"] { @@ -687,7 +660,7 @@ fn hidden_libraries_are_numbered_in_load_order() { std::fs::write(&path, gpl).unwrap(); app.session.execute("swatch.library.load", &json!({"path": s(path)})).unwrap(); } - app.ui.color_guide_limit = format!("loaded/{}", s(base.join("outside").join("a").join("b.gpl"))); + app.ui.color_guide_limit = format!("loaded/{}", s(base.join("outside").join("z").join("b.gpl"))); let r = request(&mut app, &tx, "ui.inspect", json!({})); assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.gpl (2)", "{r}"); let _ = std::fs::remove_dir_all(base); @@ -736,7 +709,7 @@ fn an_error_hides_a_sent_path_whether_or_not_a_library_was_loaded_from_it() { let (mut app, tx, base) = confined("client-echo"); let path = s(base.join("outside/b.svg")); // Another hidden library with the same file name comes first, so this one shows as `b.svg (2)`. - let first = format!("loaded/{}", s(base.join("outside/z/b.svg"))); + let first = format!("loaded/{}", s(base.join("outside/a/b.svg"))); let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": first})).unwrap(); app.ui.library_panels = vec![panel]; app.ui.color_guide_limit = format!("loaded/{path}"); @@ -808,6 +781,33 @@ fn ui_inspect_leaves_out_relative_paths_in_recorded_steps() { let _ = std::fs::remove_dir_all(base); } +/// Two libraries loaded from outside the read root under the same file name, only the second of them +/// in a library panel: `ui.inspect` and `swatch.library.list` show the second under the same id, and +/// that id finds it. +#[test] +fn ui_inspect_and_library_commands_show_a_hidden_library_under_one_id() { + let (mut app, tx, base) = confined("one-id"); + let (x, y) = (base.join("outside/x/Kit.gpl"), base.join("outside/y/Kit.gpl")); + for (path, colors) in [(&x, "0 0 0 Black\n"), (&y, "0 0 0 Black\n255 255 255 White\n")] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, format!("GIMP Palette\nName: Kit\n{colors}")).unwrap(); + app.session.execute("swatch.library.load", &json!({"path": s(path.clone())})).unwrap(); + } + let second = format!("loaded/{}", s(y.clone())); + app.ui.library_panels = vec![serde_json::from_value(json!({"kind": "swatches", "id": second, "path": s(y.clone())})).unwrap()]; + + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/Kit.gpl (2)", "{r}"); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "swatch.library.list", "params": {}})); + let libs = r["result"]["libraries"].as_array().unwrap_or_else(|| panic!("{r}")); + let loaded: Vec<&Value> = libs.iter().filter(|l| l["category"] == "loaded").map(|l| &l["id"]).collect(); + assert_eq!(loaded, [&json!("loaded/Kit.gpl"), &json!("loaded/Kit.gpl (2)")], "{r}"); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "swatch.library.get", "params": {"library": "loaded/Kit.gpl (2)"}})); + assert_eq!(r["result"]["swatches"].as_array().map(Vec::len), Some(2), "the second library: {r}"); + assert!(!r.to_string().contains("outside"), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A closed panel's former place, as `ui.docking_hidden` keeps it. fn hidden_place() -> craft_ui::docking::Location<String> { serde_json::from_value(json!({"floating": null, "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null})).unwrap() diff --git a/docs/mcp.md b/docs/mcp.md index a3c47ec71d..1881416381 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -155,7 +155,8 @@ the suite) keep the agent to the folders you give it: roots shows as `loaded/<file name>` wherever `ui_inspect` and `ui.docking` name it: its id, its panel's id, the Color Guide's `ui.color_guide_limit`, a closed panel's key in `ui.docking_hidden` and the `library` and `panel` params of a recorded action's steps in `ui.action_sets`, also when its panel is closed. A path written with - either separator counts as a path, also one saved on another platform. When another hidden library has the same + either separator counts as a path, also one saved on another platform, and outside Windows a path that starts + with a drive (`C:`) or a `\` counts as outside the roots. When another hidden library has the same file name, the libraries take `loaded/<file name>`, `loaded/<file name> (2)`, `(3)` and so on in the order of their full ids (the libraries loaded in this session first, then those only the UI state names), and each one keeps its id in both replies and in the library commands below. A hidden library skips an id that a library From d1f7888ad4eca556c6cee588b88696227625b19a Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 19:16:17 -0800 Subject: [PATCH 36/92] MCP: a hidden library keeps the id it was first shown under A library loaded from outside a confined client's read roots keeps its `loaded/<file name>` id for the session, in ui.inspect, ui.docking and the library commands, so an id taken from an earlier reply still finds the same library after another library with the same file name is loaded. swatch.library.copyToUser leaves out `path` when the file is outside the read roots, as the saves do. The ids come from the engine's concealed_library_ids, which now reads a path written with either separator, counts another platform's absolute path as outside the roots, and keeps each id in the session. --- crates/engine/src/cmd/swatchlib.rs | 60 +++++++++++++++++------- crates/engine/src/lib.rs | 3 ++ crates/engine/src/session/documents.rs | 1 + crates/engine/src/tests/library_roots.rs | 37 +++++++++++++-- crates/ui-egui/src/tests/automation.rs | 37 +++++++++++++++ docs/mcp.md | 22 +++++---- docs/target-app-parity.md | 2 +- 7 files changed, 131 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index bc48f9d323..7934de2c0f 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> { "Copy to User Defined", ["Window", "Swatch Libraries"], None, - "{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path, copied: false when nothing was written}", + "{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path (left out when it is outside the read roots), copied: false when nothing was written}", has_user_folder, copy_to_user ), @@ -368,7 +368,9 @@ impl<L: LibraryFile> Libraries<L> { .ok_or_else(|| bad(cmd, format!("`{id}` isn't a loaded library")))?; let (file, bytes) = match source.filter(|p| L::EXTS.contains(&extension(p).as_str())) { Some(p) => { - // Outside the request's read roots: the error names no path. + // The file's bytes are read: outside the request's read roots, the error names no + // path. A document's swatches (the arm below) are written from the loaded library, + // and the document is not read again. if !readable(&p) { return Err(bad(cmd, "the library's file is outside the read roots")); } @@ -486,11 +488,15 @@ fn written_elsewhere(path: &str) -> bool { /// The loaded library ids (`loaded/<path>`) whose file the request being served may not read, each /// with the id a reply shows instead: `loaded/<file name>`, as a library loaded without a path is /// named. The file name is the last part of the path after either separator, and a path saved as -/// another platform's absolute path counts as one the request may not read. The ids are those of the session's swatch, brush, symbol and graphic style libraries, then -/// `others` (ids the UI state refers to). When another id already is or shows that id, the next free -/// one of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two. The -/// session's libraries are numbered first, in the sorted order of their full ids, so a library shows -/// the same id whatever `others` holds; then `others`, in the same order. Without roots in force, none. +/// another platform's absolute path counts as one the request may not read. The ids are those of the +/// session's swatch, brush, symbol and graphic style libraries, then `others` (ids the UI state refers +/// to). When another id already is or shows that id, the next free +/// one of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two. A library +/// keeps the id it was first shown under for the rest of the session, so an id a client was given +/// finds the same library after another one with the same file name is loaded; only a library that +/// shows that id itself (one loaded later without a file under that name) moves it to the next free +/// one. Libraries shown for the first time are numbered the session's first, in the sorted order of +/// their full ids, then `others`, in the same order. Without roots in force, none. pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = String>) -> Vec<(String, String)> { if crate::file_access::current().is_none() { return vec![]; @@ -515,16 +521,32 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = Strin } } } + let mut assigned = s.shown_library_ids.lock().unwrap_or_else(|e| e.into_inner()); + let mut kept = vec![None; hidden.len()]; + for (slot, (id, _)) in kept.iter_mut().zip(&hidden) { + if let Some(shown) = assigned.get(id).filter(|shown| !taken.contains(*shown)) { + taken.insert(shown.clone()); + *slot = Some(shown.clone()); + } + } + // A new id is never one given to another library earlier in the session. + let mut given: BTreeSet<String> = assigned.values().cloned().collect(); hidden .into_iter() - .map(|(id, name)| { - let mut shown = format!("loaded/{name}"); - let mut n = 2; - while taken.contains(&shown) { - shown = format!("loaded/{name} ({n})"); - n += 1; - } - taken.insert(shown.clone()); + .zip(kept) + .map(|((id, name), kept)| { + let shown = kept.unwrap_or_else(|| { + let mut shown = format!("loaded/{name}"); + let mut n = 2; + while taken.contains(&shown) || given.contains(&shown) { + shown = format!("loaded/{name} ({n})"); + n += 1; + } + taken.insert(shown.clone()); + given.insert(shown.clone()); + assigned.insert(id.clone(), shown.clone()); + shown + }); (id, shown) }) .collect() @@ -759,8 +781,12 @@ fn copy_to_user(s: &mut Session, p: &Value) -> Result<Value> { "user" => (info, lib, false), _ => return Err(bad(C, format!("`{}` is built in and always listed", info.name))), }; - let path = s.swatch_libraries.shown_path(&info.id); - Ok(json!({"library": info.id, "name": info.name, "count": lib.len(), "path": path, "copied": copied})) + let mut out = json!({"library": info.id, "name": info.name, "count": lib.len(), "copied": copied}); + // As the saves with `user: true`: no `path` when the request may not read the file. + if let Some(path) = s.swatch_libraries.shown_path(&info.id) { + out["path"] = json!(path); + } + Ok(out) } /// The library swatches `names` stand for, each once, with the colour group it goes into (`None`: diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9890c9b08c..c8abeb094c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -151,6 +151,9 @@ pub struct Session { pub symbol_libraries: cmd::symbollib::Libraries, /// User Defined and loaded brush libraries (Window → Brush Libraries); not saved. pub brush_libraries: cmd::brushlib::Libraries, + /// The id each loaded library outside a request's read roots has been shown under, by its own id + /// ([`cmd::swatchlib::concealed_library_ids`]), kept for the session. + shown_library_ids: std::sync::Mutex<std::collections::BTreeMap<String, String>>, /// The Libraries panel's libraries of graphics, colours and text styles (`library.*`). pub libraries: cmd::library::Libraries, /// Where the saved PDF presets are kept, one file each; `None`: only for the session diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 887c740936..c5ba0410bf 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -80,6 +80,7 @@ impl Session { style_libraries: Default::default(), symbol_libraries: Default::default(), brush_libraries: Default::default(), + shown_library_ids: Default::default(), libraries: Default::default(), pdf_preset_dir: None, dictionary_file: None, diff --git a/crates/engine/src/tests/library_roots.rs b/crates/engine/src/tests/library_roots.rs index 30a8776b35..000f0e7532 100644 --- a/crates/engine/src/tests/library_roots.rs +++ b/crates/engine/src/tests/library_roots.rs @@ -214,26 +214,53 @@ fn swatch_library_replies_leave_out_paths_outside_the_read_root() { let mine = format!("loaded/{}", text(&inside)); let copied = confine(roots.as_ref(), || run(&mut s, "swatch.library.copyToUser", json!({"library": mine}))); assert_eq!((copied["library"].as_str(), copied["copied"].as_bool()), (Some("user/Kit.vcswatches"), Some(true)), "{copied}"); - assert_eq!(copied["path"], Value::Null, "{copied}"); + assert_eq!(copied.get("path"), None, "{copied}"); let saved = confine(roots.as_ref(), || run(&mut s, "swatch.library.save", json!({"user": true, "name": "Mine"}))); assert_eq!((saved["library"].as_str(), saved.get("path")), (Some("user/Mine.vcswatches"), None), "{saved}"); for reply in [&added, &copied, &saved] { assert!(!holds(reply, &base.join("a")) && !holds(reply, &base.join("user")), "{reply}"); } - // A library loaded from data under the file name keeps its id; each concealed one takes the next - // free one, so no two rows share an id. + // A library loaded from data under the file name keeps its id; the concealed one shown under that + // id takes the next free one, the other keeps its id, and no two rows share an id. let data = run(&mut s, "swatch.library.save", json!({}))["data"].as_str().unwrap().to_string(); run(&mut s, "swatch.library.load", json!({"data": data, "name": "Kit.vcswatches"})); let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({}))); let ids: Vec<Value> = loaded(&list).into_iter().map(|(id, ..)| id).collect(); - assert_eq!(ids, [json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches")], "{list}"); + assert_eq!(ids, [json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches")], "{list}"); // Copied into the user library folder, it is still found by that id, which no other library takes. confine(roots.as_ref(), || run(&mut s, "swatch.library.copyToUser", json!({"library": "loaded/Kit.vcswatches"}))); let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({}))); let ids: Vec<Value> = loaded(&list).into_iter().map(|(id, ..)| id).collect(); - assert_eq!(ids, [json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches (3)")], "{list}"); + assert_eq!(ids, [json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches (2)")], "{list}"); // The person sees the folder. let saved = run(&mut s, "swatch.library.save", json!({"user": true, "name": "Theirs"})); assert_eq!(saved["path"], json!(text(&base.join("user").join("Theirs.vcswatches")))); let _ = std::fs::remove_dir_all(base); } + +/// A concealed library keeps the id a client was shown when another library with the same file name, +/// whose full id sorts before it, is loaded later: the id still finds the first library. +#[test] +fn a_concealed_library_keeps_its_id_after_another_with_its_file_name_loads() { + let base = folder("keeps-id"); + let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap(); + let (first, later) = (base.join("b").join("Kit.vcswatches"), base.join("a").join("Kit.vcswatches")); + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 100, "height": 100})); + (SWATCHES.add)(&mut s, "First"); + run(&mut s, "swatch.library.save", json!({"path": text(&first), "name": "Kit"})); + (SWATCHES.add)(&mut s, "Second"); + run(&mut s, "swatch.library.save", json!({"path": text(&later), "name": "Kit"})); + let shown = "loaded/Kit.vcswatches"; + let count = run(&mut s, "swatch.library.load", json!({"path": text(&first)}))["count"].clone(); + let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({}))); + assert_eq!(loaded(&list), [(json!(shown), Value::Null, count)], "{list}"); + run(&mut s, "swatch.library.load", json!({"path": text(&later)})); + let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({}))); + let ids: Vec<Value> = loaded(&list).into_iter().map(|(id, ..)| id).collect(); + assert_eq!(ids, [json!(shown), json!("loaded/Kit.vcswatches (2)")], "{list}"); + let theirs = run(&mut s, "swatch.library.get", json!({"library": format!("loaded/{}", text(&first))})); + let got = confine(roots.as_ref(), || run(&mut s, "swatch.library.get", json!({"library": shown}))); + assert_eq!((&got["id"], &got["swatches"]), (&json!(shown), &theirs["swatches"]), "the first library: {got}"); + let _ = std::fs::remove_dir_all(base); +} diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index e36388bf31..aef826c538 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -808,6 +808,43 @@ fn ui_inspect_and_library_commands_show_a_hidden_library_under_one_id() { let _ = std::fs::remove_dir_all(base); } +/// A library panel command given a hidden library's shown id opens the library and answers with +/// that id, not the library's path. +#[test] +fn a_library_panel_command_answers_with_a_hidden_librarys_shown_id() { + let (mut app, tx, base) = confined("panel-shown-id"); + let path = base.join("outside/x/Kit.gpl"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "GIMP Palette\nName: Kit\n0 0 0 Black\n").unwrap(); + app.session.execute("swatch.library.load", &json!({"path": s(path.clone())})).unwrap(); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "window.swatchLibrary", "params": {"library": "loaded/Kit.gpl"}})); + assert_eq!(r["ok"], true, "{r}"); + assert!(r.to_string().contains("loaded/Kit.gpl") && !r.to_string().contains("outside"), "{r}"); + assert_eq!(app.ui.library_panels.last().map(|p| p.id.clone()), Some(format!("loaded/{}", s(path))), "the app's own id"); + // The panel id it answers with names the panel in the panel commands. + let panel = r["result"]["panel"].clone(); + assert_eq!(panel, "library:swatches:loaded/Kit.gpl", "{r}"); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "ui.libraryPanel", "params": {"panel": panel, "view": "largeList"}})); + assert_eq!((&r["ok"], &r["result"]["panel"]), (&json!(true), &panel), "{r}"); + assert!(!r.to_string().contains("outside"), "{r}"); + let r = request( + &mut app, + &tx, + "engine.execute", + json!({"command": "window.panel.move", "params": {"panel": "swatches", "anchor": panel, "zone": "tab"}}), + ); + assert_eq!(r["ok"], true, "{r}"); + assert!(!r.to_string().contains("outside"), "{r}"); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "window.panel.close", "params": {"panel": panel}})); + assert_eq!(r["ok"], true, "{r}"); + assert!(!r.to_string().contains("outside"), "{r}"); + // Closed, the panel isn't found, and the error names it by the id given. + let r = request(&mut app, &tx, "engine.execute", json!({"command": "ui.libraryPanel", "params": {"panel": panel, "view": "largeList"}})); + assert_eq!(r["ok"], false, "{r}"); + assert!(!r.to_string().contains("outside"), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A closed panel's former place, as `ui.docking_hidden` keeps it. fn hidden_place() -> craft_ui::docking::Location<String> { serde_json::from_value(json!({"floating": null, "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null})).unwrap() diff --git a/docs/mcp.md b/docs/mcp.md index 1881416381..9808d514c5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -157,11 +157,13 @@ the suite) keep the agent to the folders you give it: params of a recorded action's steps in `ui.action_sets`, also when its panel is closed. A path written with either separator counts as a path, also one saved on another platform, and outside Windows a path that starts with a drive (`C:`) or a `\` counts as outside the roots. When another hidden library has the same - file name, the libraries take `loaded/<file name>`, `loaded/<file name> (2)`, `(3)` and so on in the order of - their full ids (the libraries loaded in this session first, then those only the UI state names), and each one - keeps its id in both replies and in the library commands below. A hidden library skips an id that a library - loaded without a path already has. The other entries keep their order, and the app's own state is not - changed. A request may name such a library or its panel by the id + file name, the libraries take `loaded/<file name>`, `loaded/<file name> (2)`, `(3)` and so on in the order they + are first shown. Libraries first shown in the same reply are numbered in the order of their full ids, the + libraries loaded in this session first, then those only the UI state names. Each one keeps its id for the rest + of the session in both replies and in the library commands below, also after another library with the same + file name is loaded. A hidden library skips an id that a library loaded without a path already has, and only a + library loaded later from data under that file name, which takes the id itself, moves a hidden one to the next + free number. The other entries keep their order, and the app's own state is not changed. A request may name such a library or its panel by the id shown in a `panel`, `library` or `panels` param, in a docking destination (`anchor`, `before`, `onto`, `above`, `below`) and in `window.panel.layout`'s `action` (`ui.set {panel: "library:swatches:loaded/<file name>"}` reopens its panel), and the reply to such a request shows that id and file name wherever the library's id or path @@ -182,9 +184,13 @@ the suite) keep the agent to the folders you give it: `(2)`, `(3)` and so on as in `ui_inspect` when two have the same file name. That id finds the library in the other library commands (`swatch.library.get`, `swatch.library.add`, `brush.library`, `brush.addFromLibrary`, `symbol.library`, `symbol.addFromLibrary`, `graphicStyle.library`, `graphicStyle.addFromLibrary` and the - others that take `library`), whose replies show it too. `swatch.library.copyToUser` refuses a library whose file - is outside the read roots without naming the file, and it and the library saves with `user: true` leave out - `path` when the user library folder is outside the read roots. + others that take `library`), whose replies show it too. Over the control channel, `window.swatchLibrary` and + the other library panel commands answer with that id and its panel's id (`library:swatches:loaded/<file name>`), + and `ui.libraryPanel` and the `window.panel.*` commands take that panel id in `panel`, `anchor`, `before`, + `onto`, `above` and `below`. `swatch.library.copyToUser` refuses to copy a library file outside the read roots + without naming the file (the swatches of a document loaded from there are written from the loaded library, as + for a library loaded from data, and the document is not read again), and it and the library saves with + `user: true` leave out `path` when the user library folder is outside the read roots. - `links.list`, `links.check`, `links.info` and the `missingLinks`, `modifiedLinks` and `updatedLinks` rows of `document.open` leave out a linked file's path when the file is outside the read roots (a relative path is taken from the document's folder): `path`, `found`, and in `links.info` also `link` and `location`, with the `fileSize` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d98bb46575..0c567096a4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -769,7 +769,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). - Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`). - Linked-file and font-file searches under `--automation-read-root`: `links.findFiles` and `text.findFontFiles` replies, also later polls and stops of a search another client started, leave out the files found, a link's saved path and the folder searched outside the caller's read roots, and a failed search gives its reason without the path; `image.info` gives `link: null` for a linked file outside them; `text.addFontFiles` gives `folder: null` and the copies' file names when the Fonts folder is outside them. `crates/engine/src/tests/linkfiles.rs`, `crates/engine/src/tests/fontfiles.rs`, `crates/engine/tests/font_folder.rs`. - - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. + - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: every library stays listed, the paths of library files and the user library folder outside the read roots are `null`, a library loaded from outside them is listed and found as `loaded/<file name>` and keeps that id for the session, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. - Preferences commands under `--automation-read-root`: `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show folder preferences outside the read roots as `null` and leave out an export preset's folder outside them, and the preferences are not changed. `crates/engine/src/tests/prefs_roots.rs`. - Plug-in, recovery and script report replies under `--automation-read-root`: `plugin.list`, `plugin.info` and `plugin.safeMode` name the plug-in folder, failed files, plug-in sources and disabled plug-ins' files outside the read roots by file name or `null`, `file.recovery.*` leave out a document's original file and the recovery folder outside them, and `ui.inspect`'s `scriptReport` names files a run wrote outside them by file name. `crates/engine/src/tests/plugins.rs` (`plugin_list_leaves_out_paths_outside_the_read_root`), `crates/engine/src/tests/recovery.rs` (`recovery_replies_leave_out_paths_outside_the_read_root`), `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_script_report_leaves_out_files_outside_the_read_root`). - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. From 6d7a8249a73d1b4257969f9c7052af0f98b41b3f Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 19:44:43 -0800 Subject: [PATCH 37/92] MCP: hidden library paths are matched before the paths a request sent A sent path whose text overlapped part of a hidden library's path could keep that path from being hidden; the hidden libraries' ids and paths are now matched first. A hidden relative path is also hidden where a reply shows the absolute path it reads as from the working folder, and a hidden library sent by that other form shows as the sent path does. --- crates/ui-egui/src/control.rs | 97 +++++++++++++++++--------- crates/ui-egui/src/control/tests.rs | 41 +++++++++-- crates/ui-egui/src/tests/automation.rs | 2 +- docs/mcp.md | 6 +- 4 files changed, 106 insertions(+), 40 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 40ec47f7f5..65f7fae972 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -356,7 +356,7 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Hiding { .collect(); // The longest first, so that an id that begins another one does not replace part of it. hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); - Hiding { hidden, readable: readable_ids } + Hiding { hidden, readable: readable_ids, sent: Vec::new() } } /// What a reply hides ([`hide_library_ids`]). @@ -368,6 +368,9 @@ struct Hiding { /// The other loaded library ids the UI state and the session hold: an occurrence of a hidden id or /// path that begins one of them stays ([`begins_known_id`]). readable: std::collections::BTreeSet<String>, + /// The paths outside the read roots that the request sent ([`sent_paths`]), each as a hidden + /// library id with the id shown. They are matched after the hidden libraries. + sent: Vec<(String, String)>, } impl Hiding { @@ -387,16 +390,26 @@ impl Hiding { self.hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); } - /// Adds the hidden ids of `pairs`, each with the id shown, in place of each id this one holds for - /// the same path: written with either separator, and in any case of ASCII letters with `fold`. + /// Adds the sent paths of `pairs`, each as a hidden id with the id shown. A hidden id this one holds + /// for the same path (written with either separator, relative or as the absolute path it reads as + /// ([`absolute_form`]), and in any case of ASCII letters with `fold`) is shown as the sent path is. fn set_hidden(&mut self, pairs: Vec<(String, String)>, fold: bool) { let key = |id: &str| { - let id = id.replace('\\', "/"); + let path = id.strip_prefix("loaded/").unwrap_or(id); + let id = absolute_form(path).unwrap_or_else(|| path.to_string()).replace('\\', "/"); if fold { id.to_ascii_lowercase() } else { id } }; let sent: Vec<String> = pairs.iter().map(|(id, _)| key(id)).collect(); - self.hidden.retain(|(id, _)| !sent.contains(&key(id))); - self.add_hidden(pairs); + for (id, shown) in &mut self.hidden { + if let Some(at) = sent.iter().position(|k| *k == key(id)) { + shown.clone_from(&pairs[at].1); + } + } + for pair in pairs { + if !self.sent.iter().any(|(id, _)| *id == pair.0) { + self.sent.push(pair); + } + } } } @@ -477,31 +490,48 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet<Str /// Windows the ids and paths are matched in any case of ASCII letters. fn hide_library_ids(v: &mut Value, hiding: &Hiding) { let pairs = hidden_text(hiding); - if pairs.is_empty() { + if pairs.iter().all(Vec::is_empty) { return; } replace_in_value(v, &Rewrite { pairs, hiding, fold: cfg!(windows) }); } -/// The text [`hide_library_ids`] replaces, longest first: each hidden id with the id shown, and the -/// path in it with the file name shown, the path written with either separator too, and as `{:?}` -/// quotes it (each `\`, quote and control character escaped). -fn hidden_text(hiding: &Hiding) -> Vec<(String, String)> { +/// The text [`hide_library_ids`] replaces, in two groups: the hidden libraries' text, then the text of +/// the paths the request sent ([`Hiding::sent`]), each longest first. For each id, that is the id with +/// the id shown, and the path in it with the file name shown, the path written with either separator +/// too, and as `{:?}` quotes it (each `\`, quote and control character escaped). A relative path is +/// also replaced as the absolute path the file access check reads it as, against the working folder. +fn hidden_text(hiding: &Hiding) -> Vec<Vec<(String, String)>> { + vec![text_pairs(&hiding.hidden), text_pairs(&hiding.sent)] +} + +/// The absolute path that `path`, a relative path (one that does not start with a separator, a drive +/// or `~`), reads as from the working folder, as the file access check reads it; `None` for any other +/// path. +fn absolute_form(path: &str) -> Option<String> { + let relative = !path.starts_with(['/', '\\', '~']) && !matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()); + relative.then(|| std::path::absolute(path).ok()).flatten().map(|p| p.to_string_lossy().into_owned()) +} + +/// The text [`hidden_text`] replaces for the ids of `ids`, longest first. +fn text_pairs(ids: &[(String, String)]) -> Vec<(String, String)> { let quoted = |s: &str| { let q = format!("{s:?}"); q[1..q.len() - 1].to_string() }; let mut pairs: Vec<(String, String)> = Vec::new(); - for (id, shown) in &hiding.hidden { + for (id, shown) in ids { let path = &id["loaded/".len()..]; let name = shown.strip_prefix("loaded/").unwrap_or(shown); - let (forward, back) = (path.replace('\\', "/"), path.replace('/', "\\")); - let debug = [quoted(path), quoted(&forward), quoted(&back)].map(|p| (p, quoted(name))); - let plain = [path.to_string(), forward, back].map(|p| (p, name.to_string())); - for (path, name) in plain.into_iter().chain(debug) { - for (from, to) in [(format!("loaded/{path}"), format!("loaded/{name}")), (path, name)] { - if !pairs.iter().any(|(f, _)| *f == from) { - pairs.push((from, to)); + for path in std::iter::once(path.to_string()).chain(absolute_form(path)) { + let (forward, back) = (path.replace('\\', "/"), path.replace('/', "\\")); + let debug = [quoted(&path), quoted(&forward), quoted(&back)].map(|p| (p, quoted(name))); + let plain = [path, forward, back].map(|p| (p, name.to_string())); + for (path, name) in plain.into_iter().chain(debug) { + for (from, to) in [(format!("loaded/{path}"), format!("loaded/{name}")), (path, name)] { + if !pairs.iter().any(|(f, _)| *f == from) { + pairs.push((from, to)); + } } } } @@ -510,10 +540,10 @@ fn hidden_text(hiding: &Hiding) -> Vec<(String, String)> { pairs } -/// What [`replace_in_value`] writes: the text to replace, what the reply hides, and whether to match -/// in any case of ASCII letters. +/// What [`replace_in_value`] writes: the groups of text to replace ([`hidden_text`]), what the reply +/// hides, and whether to match in any case of ASCII letters. struct Rewrite<'a> { - pairs: Vec<(String, String)>, + pairs: Vec<Vec<(String, String)>>, hiding: &'a Hiding, fold: bool, } @@ -569,20 +599,23 @@ fn occurrences(text: &str, needle: &str, fold: bool) -> Vec<usize> { /// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, at each occurrence /// that does not begin another loaded library's id ([`begins_known_id`]). A hidden id or path written /// with `%XX` escapes (in a `file:` URL) is replaced too; the escapes around it stay. Every occurrence -/// is found in `s` as it was given, longest `from` first, and one that overlaps an occurrence already -/// found, or a loaded library's id that one began, stays: a replacement's text is not searched again. -/// With the rewrite's `fold`, the ids and paths as written are found first, so that two libraries whose -/// paths differ only in letter case keep their own ids, and then the ones written in another case. -/// `None` when nothing changed. +/// is found in `s` as it was given, the hidden libraries' text before the sent paths' text, each group +/// longest `from` first, and one that overlaps an occurrence already found, or a loaded library's id +/// that one began, stays: a replacement's text is not searched again. With the rewrite's `fold`, the +/// ids and paths of a group as written are found first, so that two libraries whose paths differ only +/// in letter case keep their own ids, and then the ones written in another case. `None` when nothing +/// changed. fn replace_text(s: &str, r: &Rewrite) -> Option<String> { let decoded = if s.contains('%') { decoded_with_spans(s).filter(|(decoded, _)| decoded != s) } else { None }; let mut found = Found::default(); - for fold in [false, true].into_iter().take(if r.fold { 2 } else { 1 }) { - for (from, to) in &r.pairs { - if let Some((text, spans)) = &decoded { - found.claim(text, Some(spans.as_slice()), from, to, fold, r); + for group in &r.pairs { + for fold in [false, true].into_iter().take(if r.fold { 2 } else { 1 }) { + for (from, to) in group { + if let Some((text, spans)) = &decoded { + found.claim(text, Some(spans.as_slice()), from, to, fold, r); + } + found.claim(s, None, from, to, fold, r); } - found.claim(s, None, from, to, fold, r); } } if found.cuts.is_empty() { diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 13890c28bb..2723dc3da8 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -28,7 +28,7 @@ fn a_reply_hides_a_hidden_library_whatever_surrounds_it() { fn hiding(hidden: &[(&str, &str)], readable: &[&str]) -> super::Hiding { let mut hidden: Vec<(String, String)> = hidden.iter().map(|(id, shown)| (id.to_string(), shown.to_string())).collect(); hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); - super::Hiding { hidden, readable: readable.iter().map(|id| id.to_string()).collect() } + super::Hiding { hidden, readable: readable.iter().map(|id| id.to_string()).collect(), sent: Vec::new() } } /// [`super::hide_library_ids`] on `reply`, under a read root made for `tag`. @@ -125,10 +125,41 @@ fn a_reply_hides_a_path_in_any_case_and_escaped() { fn a_sent_path_replaces_a_hidden_id_for_the_same_path() { let mut hidden = hiding(&[(r"loaded/C:\Libs\b.svg", "loaded/b.svg (2)"), ("loaded//other/b.svg", "loaded/b.svg")], &[]); hidden.set_hidden(vec![("loaded/c:/libs/B.svg".to_string(), "loaded/B.svg".to_string())], true); - let ids: Vec<&str> = hidden.hidden.iter().map(|(id, _)| id.as_str()).collect(); - assert_eq!(ids, ["loaded/c:/libs/B.svg", "loaded//other/b.svg"]); - hidden.set_hidden(vec![("loaded//OTHER/b.svg".to_string(), "loaded/b.svg".to_string())], false); - assert_eq!(hidden.hidden.len(), 3, "{hidden:?}"); + let shown: Vec<&str> = hidden.hidden.iter().map(|(_, shown)| shown.as_str()).collect(); + assert_eq!(shown, ["loaded/B.svg", "loaded/b.svg"], "{hidden:?}"); + assert_eq!(hidden.sent, [("loaded/c:/libs/B.svg".to_string(), "loaded/B.svg".to_string())]); + hidden.set_hidden(vec![("loaded//OTHER/b.svg".to_string(), "loaded/x.svg".to_string())], false); + assert_eq!((hidden.hidden[1].1.as_str(), hidden.sent.len()), ("loaded/b.svg", 2), "{hidden:?}"); + // A library loaded by a relative path, and the absolute path it reads as, sent: the sent path's + // file name is shown for both, not the library's numbered id. + let mut hidden = hiding(&[("loaded/data/icon.svg", "loaded/icon.svg (2)")], &[]); + let absolute = std::path::absolute("data/icon.svg").unwrap().to_string_lossy().into_owned(); + hidden.set_hidden(vec![(format!("loaded/{absolute}"), "loaded/icon.svg".to_string())], false); + let mut reply = json!({"error": format!("can't read \"data/icon.svg\" or {absolute}")}); + hide_under_a_root("sent-absolute", &mut reply, &hidden); + assert_eq!(reply["error"], "can't read \"icon.svg\" or icon.svg", "{reply}"); +} + +/// A sent path whose text overlaps part of a hidden library's path does not keep that library's path +/// from being hidden: the hidden libraries are matched first. +#[test] +fn a_sent_path_does_not_stop_a_hidden_library_path_from_being_hidden() { + let mut hidden = hiding(&[("loaded//home/secret/dir/lib", "loaded/lib")], &[]); + hidden.set_hidden(vec![("loaded//dir/lib/sent-folder/longer".to_string(), "loaded/longer".to_string())], false); + let mut reply = json!({"error": "can't read /home/secret/dir/lib/sent-folder/longer"}); + hide_under_a_root("sent-overlap", &mut reply, &hidden); + assert_eq!(reply["error"], "can't read lib/sent-folder/longer", "{reply}"); +} + +/// A hidden relative path is also hidden where a reply shows it as the absolute path it reads as from +/// the working folder. +#[test] +fn a_reply_hides_a_hidden_relative_path_written_as_absolute() { + let hidden = hiding(&[("loaded/data/icon.svg", "loaded/icon.svg")], &[]); + let absolute = std::path::absolute("data/icon.svg").unwrap().to_string_lossy().into_owned(); + let mut reply = json!({"error": format!("can't read {absolute}")}); + hide_under_a_root("relative-absolute", &mut reply, &hidden); + assert_eq!(reply["error"], "can't read icon.svg", "{reply}"); } /// The paths outside the read root that a request sends, as a path, a `file:` URL (also one that names diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index aef826c538..28325fc08e 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -905,7 +905,7 @@ fn ui_inspect_and_ui_docking_give_a_hidden_library_the_same_id() { let ui = &r["result"]["ui"]; assert_eq!( (ui["color_guide_limit"].as_str(), ui["open_panel"].as_str()), - (Some("loaded/b.svg (2)"), Some("library:swatches:loaded/b.svg")), + (Some("loaded/b.svg"), Some("library:swatches:loaded/b.svg (2)")), "{r}" ); let d = request(&mut app, &tx, "ui.docking", json!({})); diff --git a/docs/mcp.md b/docs/mcp.md index 9808d514c5..0d224f247b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -175,8 +175,10 @@ the suite) keep the agent to the folders you give it: another loaded library's whole id with no `.` or `..` part, so a readable library's id that begins with a hidden one stays. A reply also shows a path outside the read roots that the request sent by its file name, whether or not a library was loaded from it and whether the request succeeded or failed, so that the reply reads the same either way. A path param (`path`, `file`, - `destinationFolder` and the like) counts with a relative path too. A relative path is not replaced where it ends a - longer path (`/work/data/icon.svg` keeps `data/icon.svg`). Encoded file data, a base64 string under `dataBase64`, + `destinationFolder` and the like) counts with a relative path too. The hidden libraries' ids and paths are + matched before the paths the request sent, so a sent path written over part of a hidden library's path does not + keep that path in the reply. A relative path is not replaced where it ends a longer path (`/work/data/icon.svg` + keeps `data/icon.svg`), and the absolute path it reads as from the app's working folder is replaced too. Encoded file data, a base64 string under `dataBase64`, `pngBase64` or `thumbnailBase64`, is sent as it is. - `swatch.library.list`, `brush.libraries`, `symbol.libraries` and `graphicStyle.libraries` list every library but leave out the paths outside the read roots: a library's `path` and the user library folder in `userFolder` From 97b77fe4fdef211dd7010fa6963427a205db0208 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 19:59:09 -0800 Subject: [PATCH 38/92] MCP: another platform's absolute path is outside the read roots everywhere Outside Windows, a path that starts with a drive (`C:`) or a `\` is read as a name in the working folder, so with the working folder inside a read root it passed the read check. ui.inspect then kept it in recorded action steps and replies did not hide it when a request sent it. The check now counts such a path as outside the read roots wherever the control channel and the library commands check a path. --- crates/engine/src/cmd/swatchlib.rs | 15 ++++----------- crates/engine/src/file_access.rs | 7 +++++++ crates/ui-egui/src/control.rs | 5 +++-- crates/ui-egui/src/control/tests.rs | 15 +++++++++++++++ 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 7934de2c0f..6f007c182d 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -472,17 +472,10 @@ fn file_name(path: &str) -> String { } /// May the request being served read `path` ([`crate::file_access::check_read`])? Always, without -/// roots in force. +/// roots in force. Another platform's absolute path ([`crate::file_access::written_elsewhere`]) is +/// never readable under a read root. fn readable(path: &str) -> bool { - crate::file_access::check_read(path).is_ok() -} - -/// Is `path`, under a read root, written as an absolute path of another platform: outside Windows, one -/// that starts with a drive (`C:`) or a `\`? The file access check would read it as a name in the -/// working folder. -fn written_elsewhere(path: &str) -> bool { - let foreign = path.starts_with('\\') || matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()); - !cfg!(windows) && foreign && crate::file_access::current().is_some() + !crate::file_access::written_elsewhere(path) && crate::file_access::check_read(path).is_ok() } /// The loaded library ids (`loaded/<path>`) whose file the request being served may not read, each @@ -514,7 +507,7 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = Strin (name, path) }); match name { - Some((name, path)) if name != path && (written_elsewhere(path) || !readable(path)) => hidden.push((id, name)), + Some((name, path)) if name != path && !readable(path) => hidden.push((id, name)), _ => { taken.insert(id); } diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 9d05b17272..6ca724d810 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -215,6 +215,13 @@ pub fn check_read(path: &str) -> Result<(), String> { current().map_or(Ok(()), |r| r.check(path, Access::Read)) } +/// Is `path`, under a read root, written as an absolute path of another platform: outside Windows, one +/// that starts with a drive (`C:`) or a `\`? [`check_read`] reads it as a name in the working folder. +pub fn written_elsewhere(path: &str) -> bool { + let foreign = path.starts_with('\\') || matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()); + !cfg!(windows) && foreign && current().is_some() +} + /// Does a command param named `key` hold a file or folder path: `path`, `paths`, `file`, `files`, /// `folder`, or a name ending in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`, /// `destinationFolder`)? diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 65f7fae972..79d614b9d0 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -824,9 +824,10 @@ const SCRATCH_CHOICES: [&str; 2] = ["Startup", "None"]; /// May the request being served read `path`? Under an automation read root (a control client's /// request), `ui.inspect` leaves out a path outside the root, as `ui.menu.list` leaves out a recent -/// file or a listed script outside it. +/// file or a listed script outside it. Another platform's absolute path +/// ([`vectorcraft_engine::file_access::written_elsewhere`]) is never readable under a read root. fn readable(path: &str) -> bool { - vectorcraft_engine::file_access::check_read(path).is_ok() + !vectorcraft_engine::file_access::written_elsewhere(path) && vectorcraft_engine::file_access::check_read(path).is_ok() } /// Is `s` a path the request may not read, written as an absolute path (either separator, with or diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 2723dc3da8..d144ffd953 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -263,6 +263,21 @@ fn a_library_saved_with_another_platforms_path_is_hidden() { assert_eq!(hiding.hidden, [(r"loaded/C:\Users\someone\b.svg".to_string(), "loaded/b.svg".to_string())], "{hiding:?}"); } +/// Another platform's absolute path is outside the read root, also when the working folder is inside +/// it: a request that sends one sees it by its file name. +#[test] +fn another_platforms_absolute_path_is_unreadable() { + let here = std::env::current_dir().unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&here], &[]).unwrap(); + let path = r"C:\Users\someone\private.svg"; + let mut sent = Vec::new(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || { + assert!(!super::readable(path) && super::unreadable_absolute(path)); + super::sent_paths(&json!({"list": [path]}), &mut sent); + }); + assert_eq!(sent, [(format!("loaded/{path}"), "loaded/private.svg".to_string())]); +} + /// A hidden relative path is hidden where it stands alone or after `./`, and stays where it ends a /// longer path. #[test] From d4bbce8982c85b4edd548590f08f9772433f7108 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:56:02 -0800 Subject: [PATCH 39/92] MCP: hidden library names follow the platform's path rules - Outside Windows a `\` stays part of a hidden library's or sent path's file name; another platform's absolute path is still split on `\`. - A hidden relative path is matched after leading `./` and `../` parts only when they start the path, so `/work/./data/icon.svg` keeps its text. - `ui.inspect` and `ui.docking` replies are hidden once, so a library at the top of the file system shows as `loaded/<file name>` with its separator. --- crates/engine/src/cmd/swatchlib.rs | 19 ++++++---- crates/engine/src/tests/library_roots.rs | 19 ++++++++++ crates/ui-egui/src/control.rs | 39 +++++++++++---------- crates/ui-egui/src/control/tests.rs | 44 ++++++++++++++++++++++-- docs/mcp.md | 3 +- 5 files changed, 95 insertions(+), 29 deletions(-) diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 6f007c182d..8101cfff97 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -471,6 +471,15 @@ fn file_name(path: &str) -> String { std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()) } +/// The file name a concealed library or path is shown by: the last part of `path` as this platform +/// splits a path, so that outside Windows a `\` stays part of the name. Another platform's absolute +/// path ([`crate::file_access::written_elsewhere`]), and a path this platform gives no file name (one +/// that ends in `..`), are split after the last `/` or `\` instead. `path` itself when it has no such part. +pub fn concealed_file_name(path: &str) -> String { + let native = (!crate::file_access::written_elsewhere(path)).then(|| std::path::Path::new(path).file_name()).flatten(); + native.map_or_else(|| path.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(path).to_string(), |n| n.to_string_lossy().into_owned()) +} + /// May the request being served read `path` ([`crate::file_access::check_read`])? Always, without /// roots in force. Another platform's absolute path ([`crate::file_access::written_elsewhere`]) is /// never readable under a read root. @@ -480,8 +489,8 @@ fn readable(path: &str) -> bool { /// The loaded library ids (`loaded/<path>`) whose file the request being served may not read, each /// with the id a reply shows instead: `loaded/<file name>`, as a library loaded without a path is -/// named. The file name is the last part of the path after either separator, and a path saved as -/// another platform's absolute path counts as one the request may not read. The ids are those of the +/// named. The file name is [`concealed_file_name`], and a path saved as another platform's absolute +/// path counts as one the request may not read. The ids are those of the /// session's swatch, brush, symbol and graphic style libraries, then `others` (ids the UI state refers /// to). When another id already is or shows that id, the next free /// one of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two. A library @@ -501,11 +510,7 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = Strin let mut taken = BTreeSet::new(); for ids in [session, others] { for id in ids { - let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| { - // Either separator: a path saved on another platform counts as a path here too. - let name = path.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(path).to_string(); - (name, path) - }); + let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| (concealed_file_name(path), path)); match name { Some((name, path)) if name != path && !readable(path) => hidden.push((id, name)), _ => { diff --git a/crates/engine/src/tests/library_roots.rs b/crates/engine/src/tests/library_roots.rs index 000f0e7532..5ce4a3757e 100644 --- a/crates/engine/src/tests/library_roots.rs +++ b/crates/engine/src/tests/library_roots.rs @@ -238,6 +238,25 @@ fn swatch_library_replies_leave_out_paths_outside_the_read_root() { let _ = std::fs::remove_dir_all(base); } +/// Outside Windows a `\` is part of a file name: a concealed library saved at `<folder>/weird\name.gpl` +/// is shown under its whole file name. Another platform's absolute path is still named by the part +/// after its last `\`. +#[cfg(not(windows))] +#[test] +fn a_concealed_library_keeps_a_backslash_in_its_file_name() { + let base = folder("backslash-name"); + let roots = AutomationRoots::new(&[&base.join("in")], &[]).unwrap(); + let native = format!("loaded/{}/weird\\name.gpl", text(&base.join("a"))); + let foreign = r"loaded/C:\Users\someone\other.gpl".to_string(); + let s = Session::new(); + let mut hidden = confine(roots.as_ref(), || cmd::swatchlib::concealed_library_ids(&s, [native.clone(), foreign.clone()])); + let _ = std::fs::remove_dir_all(base); + hidden.sort(); + let mut want = vec![(foreign, "loaded/other.gpl".to_string()), (native, "loaded/weird\\name.gpl".to_string())]; + want.sort(); + assert_eq!(hidden, want); +} + /// A concealed library keeps the id a client was shown when another library with the same file name, /// whose full id sorts before it, is loaded later: the id still finds the first library. #[test] diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 79d614b9d0..d0f8c28fad 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -251,7 +251,7 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { fields.remove("floating_panels"); leave_out_unreadable_paths(fields); } - let mut inspected = json!({ + json!({ "scriptRunning": app.session.script_running(), "scriptReport": app.session.shown_script_report(), "tool": app.session.tool_id(), @@ -307,9 +307,7 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { // A native file dialog open off the UI thread (`open`, `place`, `save`, `folder`), waiting // for the person at the computer: what asked for it runs again when it answers. "fileDialog": app.picks.waiting_kind(), - }); - hide_library_ids(&mut inspected, &unreadable_library_ids(app)); - inspected + }) } /// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold panel ids: the panel @@ -435,7 +433,10 @@ fn collect_sent_paths(v: &Value, path_param: bool, out: &mut Vec<(String, String Some(None) => percent_decoded(&s[5..]), None => s.to_string(), }; - let Some(name) = path.rsplit(['/', '\\']).find(|part| !part.is_empty()).filter(|name| *name != path) else { return }; + let name = vectorcraft_engine::cmd::swatchlib::concealed_file_name(&path); + if name == path { + return; + } let pair = (format!("loaded/{path}"), format!("loaded/{name}")); if !out.contains(&pair) { out.push(pair); @@ -709,17 +710,22 @@ fn decoded_with_spans(text: &str) -> Option<(String, Vec<(usize, usize)>)> { } /// Is `from`, a relative path (one that does not start with a separator, a drive or `loaded/`), at -/// `text[at..]` the end of a longer path: is it preceded by a letter, a digit, `_`, `-`, `.`, `~` or a -/// separator other than one after `.` or `..`? `/work/data/icon.svg` does not hold the path -/// `data/icon.svg`; `./data/icon.svg` does. +/// `text[at..]` the end of a longer path: after any `.` and `..` parts just before it, is it preceded by +/// a letter, a digit, `_`, `-`, `.`, `~` or a separator? `/work/data/icon.svg` and +/// `/work/./data/icon.svg` do not hold the path `data/icon.svg`; `./data/icon.svg` and +/// `../data/icon.svg` do. fn ends_a_longer_path(text: &str, at: usize, from: &str) -> bool { let absolute = from.starts_with(['/', '\\']) || from.starts_with("loaded/") || matches!(from.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()); - let before = &text[..at]; - !absolute - && !before.ends_with("./") - && !before.ends_with(".\\") - && before.ends_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '~' | '/' | '\\')) + let mut before = &text[..at]; + while let Some(head) = before.strip_suffix(['/', '\\']) { + let Some(rest) = head.strip_suffix("..").or_else(|| head.strip_suffix('.')) else { break }; + before = rest; + if !before.ends_with(['/', '\\']) { + break; + } + } + !absolute && before.ends_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '~' | '/' | '\\')) } /// Does text that follows a path continue it: a letter, a digit, `_` or a separator, after any `.`, @@ -1191,12 +1197,7 @@ fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) "engine.commands" => ok(all_commands(app)), "document.inspect" => wrap(app.run("document.inspect", p.clone())), "ui.inspect" => ok(inspect(app, ctx)), - "ui.docking" => { - let mut docking = crate::panel_docking::inspect(app, ctx); - let ids = unreadable_library_ids(app); - hide_library_ids(&mut docking, &ids); - ok(docking) - } + "ui.docking" => ok(crate::panel_docking::inspect(app, ctx)), // A request that stops with a panic quoting the status text, after clearing the Color Guide's // limit with `forget`. #[cfg(test)] diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index d144ffd953..7f0a1177a7 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -278,14 +278,54 @@ fn another_platforms_absolute_path_is_unreadable() { assert_eq!(sent, [(format!("loaded/{path}"), "loaded/private.svg".to_string())]); } -/// A hidden relative path is hidden where it stands alone or after `./`, and stays where it ends a -/// longer path. +/// A hidden relative path is hidden where it stands alone or after leading `./` and `../` parts, and +/// stays where it ends a longer path, also one with a `.` or `..` part before it. #[test] fn a_reply_keeps_a_longer_path_that_ends_with_a_hidden_relative_path() { let hidden = hiding(&[("loaded/data/icon.svg", "loaded/icon.svg")], &[]); let mut reply = json!({"result": "kept /work/data/icon.svg and xdata/icon.svg, hid ./data/icon.svg, \"data/icon.svg\" and loaded/data/icon.svg"}); hide_under_a_root("relative", &mut reply, &hidden); assert_eq!(reply["result"], "kept /work/data/icon.svg and xdata/icon.svg, hid ./icon.svg, \"icon.svg\" and loaded/icon.svg", "{reply}"); + let mut reply = json!({"result": "kept /allowed/./data/icon.svg, /allowed/../data/icon.svg and a./data/icon.svg, hid ../data/icon.svg and './../data/icon.svg'"}); + hide_under_a_root("relative-dots", &mut reply, &hidden); + assert_eq!( + reply["result"], "kept /allowed/./data/icon.svg, /allowed/../data/icon.svg and a./data/icon.svg, hid ../icon.svg and './../icon.svg'", + "{reply}" + ); +} + +/// Outside Windows a `\` is part of a file name: a sent path outside the read root is shown by its +/// whole file name. +#[cfg(not(windows))] +#[test] +fn a_sent_path_keeps_a_backslash_in_its_file_name() { + let root = std::env::temp_dir().join(format!("vc-sent-backslash-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + let mut sent = Vec::new(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(&json!({"path": "/out/weird\\name.svg"}), &mut sent)); + let _ = std::fs::remove_dir_all(root); + assert_eq!(sent, [("loaded//out/weird\\name.svg".to_string(), "loaded/weird\\name.svg".to_string())]); +} + +/// `ui.inspect` and `ui.docking` show a hidden library whose file is at the top of the file system +/// (`/b.svg`) as `loaded/b.svg`, with its separator. +#[test] +fn a_hidden_library_at_the_top_of_the_file_system_keeps_its_shown_id() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + let panel = "library:swatches:loaded//b.svg".to_string(); + app.ui.color_guide_limit = "loaded//b.svg".to_string(); + app.ui.open_panel = Some(panel); + let root = std::env::temp_dir().join(format!("vc-top-level-library-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + let (inspected, docking) = vectorcraft_engine::file_access::confine(roots.as_ref(), || { + (input_request(&mut app, "ui.inspect", json!({})), input_request(&mut app, "ui.docking", json!({}))) + }); + let _ = std::fs::remove_dir_all(root); + assert_eq!(inspected["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{inspected}"); + assert_eq!(inspected["result"]["ui"]["open_panel"], "library:swatches:loaded/b.svg", "{inspected}"); + assert_eq!(docking["result"]["flyout"], "library:swatches:loaded/b.svg", "{docking}"); } /// A path outside the read root that a request sent is shown by its file name in a reply that succeeded diff --git a/docs/mcp.md b/docs/mcp.md index 0d224f247b..c4c649cb3c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -156,7 +156,8 @@ the suite) keep the agent to the folders you give it: Color Guide's `ui.color_guide_limit`, a closed panel's key in `ui.docking_hidden` and the `library` and `panel` params of a recorded action's steps in `ui.action_sets`, also when its panel is closed. A path written with either separator counts as a path, also one saved on another platform, and outside Windows a path that starts - with a drive (`C:`) or a `\` counts as outside the roots. When another hidden library has the same + with a drive (`C:`) or a `\` counts as outside the roots. Outside Windows, any other path keeps a `\` in its + file name (`/libs/a\b.ase` shows as `loaded/a\b.ase`). When another hidden library has the same file name, the libraries take `loaded/<file name>`, `loaded/<file name> (2)`, `(3)` and so on in the order they are first shown. Libraries first shown in the same reply are numbered in the order of their full ids, the libraries loaded in this session first, then those only the UI state names. Each one keeps its id for the rest From 2ec278280a9b6b415f62e9b0f6267eedeebafd00 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:42:59 -0800 Subject: [PATCH 40/92] MCP: ui.dock targets, shortcut keys and native backslash paths keep hidden library ids - ui.dock's move takes a hidden panel's shown id in target, as the other docking destinations do. - Shortcut overrides keyed panel:<panel id>, saved and in an open Keyboard Shortcuts dialog, show the panel id shown for a library outside the read roots, also when nothing else refers to it. - Outside Windows, a native path is matched only as written: a sent /out/weird/name.svg no longer renames a hidden library at /out/weird\name.svg, and a reply does not read one as a spelling of the other. Windows paths are still matched with either separator. --- crates/ui-egui/src/control.rs | 56 ++++++++++++++++++-------- crates/ui-egui/src/control/tests.rs | 14 +++++++ crates/ui-egui/src/tests/automation.rs | 29 +++++++++++++ docs/mcp.md | 11 +++-- 4 files changed, 90 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d0f8c28fad..ef747f8476 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -320,7 +320,8 @@ const PANEL_FIELDS: [&str; 7] = /// request may not read, each with the id a reply shows instead: `loaded/<file name>`, as a library /// loaded without a path is named. The UI state refers to a library by its bare id in a library panel /// and the Color Guide's limit, by a panel id (`library:<kind>:loaded/<path>`) in the fields of -/// [`PANEL_FIELDS`] and the workspaces' layouts, and by either in the `library` and `panel` params of +/// [`PANEL_FIELDS`], the workspaces' layouts and the keys of shortcut overrides (`panel:<panel id>`, +/// saved and in an open Keyboard Shortcuts dialog), and by either in the `library` and `panel` params of /// recorded actions' steps. Text the person types (a command palette query, a workspace's or an /// action's name) is not read. The session's loaded libraries whose file the request may not read are /// hidden too. The ids are numbered as the library commands number them @@ -332,6 +333,12 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Hiding { for field in PANEL_FIELDS { collect_library_panel_ids(&ui[field], &mut rest); } + // A shortcut for showing a panel is keyed `panel:<id>`, saved and in an open Keyboard Shortcuts dialog. + for overrides in [&ui["shortcut_overrides"], &ui["dialog"]["fields"]["overrides"]] { + for panel in overrides.as_object().into_iter().flat_map(|o| o.keys()).filter_map(|key| key.strip_prefix("panel:")) { + collect_library_panel_ids(&json!(panel), &mut rest); + } + } collect_library_params(&ui["action_sets"], &mut rest); for workspace in ["custom_workspaces", "workspace_changes"].iter().filter_map(|k| ui[k].as_array()).flatten() { for (_, layout) in workspace.as_object().into_iter().flatten().filter(|(k, _)| k.as_str() != "name") { @@ -389,15 +396,18 @@ impl Hiding { } /// Adds the sent paths of `pairs`, each as a hidden id with the id shown. A hidden id this one holds - /// for the same path (written with either separator, relative or as the absolute path it reads as - /// ([`absolute_form`]), and in any case of ASCII letters with `fold`) is shown as the sent path is. + /// for the same path (written with either separator when it is a Windows path + /// ([`splits_at_backslash`]), relative or as the absolute path it reads as ([`absolute_form`]), and + /// in any case of ASCII letters with `fold`) is shown as the sent path is. fn set_hidden(&mut self, pairs: Vec<(String, String)>, fold: bool) { let key = |id: &str| { let path = id.strip_prefix("loaded/").unwrap_or(id); - let id = absolute_form(path).unwrap_or_else(|| path.to_string()).replace('\\', "/"); - if fold { id.to_ascii_lowercase() } else { id } + let id = absolute_form(path).unwrap_or_else(|| path.to_string()); + let windows = splits_at_backslash(&id); + let id = if windows { id.replace('\\', "/") } else { id }; + (windows, if fold { id.to_ascii_lowercase() } else { id }) }; - let sent: Vec<String> = pairs.iter().map(|(id, _)| key(id)).collect(); + let sent: Vec<(bool, String)> = pairs.iter().map(|(id, _)| key(id)).collect(); for (id, shown) in &mut self.hidden { if let Some(at) = sent.iter().position(|k| *k == key(id)) { shown.clone_from(&pairs[at].1); @@ -499,13 +509,21 @@ fn hide_library_ids(v: &mut Value, hiding: &Hiding) { /// The text [`hide_library_ids`] replaces, in two groups: the hidden libraries' text, then the text of /// the paths the request sent ([`Hiding::sent`]), each longest first. For each id, that is the id with -/// the id shown, and the path in it with the file name shown, the path written with either separator -/// too, and as `{:?}` quotes it (each `\`, quote and control character escaped). A relative path is -/// also replaced as the absolute path the file access check reads it as, against the working folder. +/// the id shown, and the path in it with the file name shown, a Windows path ([`splits_at_backslash`]) +/// written with either separator too, and as `{:?}` quotes it (each `\`, quote and control character +/// escaped). A relative path is also replaced as the absolute path the file access check reads it as, +/// against the working folder. fn hidden_text(hiding: &Hiding) -> Vec<Vec<(String, String)>> { vec![text_pairs(&hiding.hidden), text_pairs(&hiding.sent)] } +/// Is `path` a Windows path, whose parts a `\` separates as well as a `/`: any path on Windows, and +/// elsewhere one that starts with a drive (`C:`) or a `\`? Outside Windows, a `\` in any other path is +/// part of a name. +fn splits_at_backslash(path: &str) -> bool { + cfg!(windows) || path.starts_with('\\') || matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()) +} + /// The absolute path that `path`, a relative path (one that does not start with a separator, a drive /// or `~`), reads as from the working folder, as the file access check reads it; `None` for any other /// path. @@ -525,10 +543,15 @@ fn text_pairs(ids: &[(String, String)]) -> Vec<(String, String)> { let path = &id["loaded/".len()..]; let name = shown.strip_prefix("loaded/").unwrap_or(shown); for path in std::iter::once(path.to_string()).chain(absolute_form(path)) { - let (forward, back) = (path.replace('\\', "/"), path.replace('/', "\\")); - let debug = [quoted(&path), quoted(&forward), quoted(&back)].map(|p| (p, quoted(name))); - let plain = [path, forward, back].map(|p| (p, name.to_string())); - for (path, name) in plain.into_iter().chain(debug) { + // A Windows path is also written with the other separator; outside Windows, a native + // path's `\` is part of a name, so that path is written only as it is. + let mut spellings = vec![path.clone()]; + if splits_at_backslash(&path) { + spellings.extend([path.replace('\\', "/"), path.replace('/', "\\")].into_iter().filter(|p| splits_at_backslash(p))); + } + let debug: Vec<_> = spellings.iter().map(|p| (quoted(p), quoted(name))).collect(); + let plain = spellings.into_iter().map(|p| (p, name.to_string())); + for (path, name) in plain.chain(debug) { for (from, to) in [(format!("loaded/{path}"), format!("loaded/{name}")), (path, name)] { if !pairs.iter().any(|(f, _)| *f == from) { pairs.push((from, to)); @@ -1048,9 +1071,10 @@ fn packet_mods(value: Option<&Value>) -> Result<Option<Mods>, String> { /// The params that name a library or a library panel, at any depth of a request (`ui.set {panel}`, /// `engine.execute {params: {panel}}`, `ui.libraryPanel {panel, library}`), with the docking -/// commands' destinations (`window.panel.move {anchor, before}`, `window.panel.float {onto, above, -/// below}`) and a list of panels. -const LIBRARY_PARAMS: [&str; 8] = ["panel", "library", "anchor", "before", "onto", "above", "below", "panels"]; +/// commands' destinations (`window.panel.move {anchor, before}`, `ui.dock {target}`, +/// `window.panel.float {onto, above, below}`) and a list of panels. Only a whole id is translated +/// ([`swap_whole_ids`]), so other text in these params stays. +const LIBRARY_PARAMS: [&str; 9] = ["panel", "library", "anchor", "target", "before", "onto", "above", "below", "panels"]; /// The param of `window.panel.layout` that holds a serialized docking action: each panel id in it is /// translated. diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 7f0a1177a7..0b19350f6f 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -140,6 +140,20 @@ fn a_sent_path_replaces_a_hidden_id_for_the_same_path() { assert_eq!(reply["error"], "can't read \"icon.svg\" or icon.svg", "{reply}"); } +/// Outside Windows, a `\` in a native path is part of a name: a sent path that differs from a hidden +/// library's path only by `/` in place of that `\` names another file, so the library keeps its id +/// and a reply does not read either path as a spelling of the other. +#[cfg(not(windows))] +#[test] +fn a_backslash_in_a_native_path_keeps_two_paths_apart() { + let mut hidden = hiding(&[(r"loaded//out/weird\name.svg", r"loaded/weird\name.svg")], &[]); + hidden.set_hidden(vec![("loaded//out/weird/name.svg".to_string(), "loaded/name.svg".to_string())], false); + assert_eq!(hidden.hidden[0].1, r"loaded/weird\name.svg", "{hidden:?}"); + let mut reply = json!({"error": r"no /out/weird\name.svg, /out/weird/name.svg or \out\weird\name.svg"}); + hide_under_a_root("native-backslash", &mut reply, &hidden); + assert_eq!(reply["error"], r"no weird\name.svg, name.svg or \out\weird\name.svg", "{reply}"); +} + /// A sent path whose text overlaps part of a hidden library's path does not keep that library's path /// from being hidden: the hidden libraries are matched first. #[test] diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 28325fc08e..68cb169c8c 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -1042,6 +1042,17 @@ fn ui_set_reopens_a_hidden_librarys_panel_by_its_shown_id() { ); assert_eq!(r["ok"], true, "{r}"); assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + // So does `ui.dock`'s move, whose destination is `target`. + let r = request( + &mut app, + &tx, + "engine.execute", + json!({"command": "ui.dock", "params": {"operation": "move", "panel": "properties", "target": shown, "zone": "tab"}}), + ); + assert_eq!(r["ok"], true, "{r}"); + let layout = crate::panel_docking::layout_of(&app.ui); + assert!(layout.contains(&real) && !layout.contains(&shown.to_string()), "no second panel: {layout:?}"); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); // An error that quotes the panel shows the id the client gave, and other text is not rewritten. let r = request(&mut app, &tx, "ui.set", json!({"panel": "library:bogus:loaded/b.svg"})); assert_eq!(r["ok"], false, "{r}"); @@ -1051,6 +1062,24 @@ fn ui_set_reopens_a_hidden_librarys_panel_by_its_shown_id() { let _ = std::fs::remove_dir_all(base); } +/// A shortcut for showing the panel of a library outside the read root, saved and in an open Keyboard +/// Shortcuts dialog, is keyed by the panel id shown, also when nothing else refers to the library. +#[test] +fn ui_inspect_hides_a_library_panels_path_in_shortcut_overrides() { + let (mut app, tx, base) = confined("shortcut-hidden-panel"); + let real = format!("panel:library:swatches:loaded/{}", s(base.join("outside/b.svg"))); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, &real, "Cmd+Alt+Shift+F7"); + app.ui.dialog = Some(crate::state::Dialog::new("shortcuts", json!({"overrides": { real.clone(): "Cmd+Alt+Shift+F8" }}))); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let shown = "panel:library:swatches:loaded/b.svg"; + let ui = &r["result"]["ui"]; + assert_eq!(ui["shortcut_overrides"][shown], "Cmd+Alt+Shift+F7", "{r}"); + assert_eq!(ui["dialog"]["fields"]["overrides"][shown], "Cmd+Alt+Shift+F8", "{r}"); + let hidden = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A client without automation roots sees every loaded library's full id in `ui.inspect`. #[test] fn ui_inspect_shows_full_library_ids_without_a_read_root() { diff --git a/docs/mcp.md b/docs/mcp.md index c4c649cb3c..5cb8df4df8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -154,10 +154,13 @@ the suite) keep the agent to the folders you give it: empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the roots shows as `loaded/<file name>` wherever `ui_inspect` and `ui.docking` name it: its id, its panel's id, the Color Guide's `ui.color_guide_limit`, a closed panel's key in `ui.docking_hidden` and the `library` and `panel` - params of a recorded action's steps in `ui.action_sets`, also when its panel is closed. A path written with + params of a recorded action's steps in `ui.action_sets`, and the keys of shortcut overrides (`panel:<panel id>` + in `ui.shortcut_overrides` and an open Keyboard Shortcuts dialog's `ui.dialog.fields.overrides`), also when its + panel is closed. A path written with either separator counts as a path, also one saved on another platform, and outside Windows a path that starts with a drive (`C:`) or a `\` counts as outside the roots. Outside Windows, any other path keeps a `\` in its - file name (`/libs/a\b.ase` shows as `loaded/a\b.ase`). When another hidden library has the same + file name (`/libs/a\b.ase` shows as `loaded/a\b.ase`), and a reply matches it only as written, not with `/` + in place of the `\`. When another hidden library has the same file name, the libraries take `loaded/<file name>`, `loaded/<file name> (2)`, `(3)` and so on in the order they are first shown. Libraries first shown in the same reply are numbered in the order of their full ids, the libraries loaded in this session first, then those only the UI state names. Each one keeps its id for the rest @@ -165,8 +168,8 @@ the suite) keep the agent to the folders you give it: file name is loaded. A hidden library skips an id that a library loaded without a path already has, and only a library loaded later from data under that file name, which takes the id itself, moves a hidden one to the next free number. The other entries keep their order, and the app's own state is not changed. A request may name such a library or its panel by the id - shown in a `panel`, `library` or `panels` param, in a docking destination (`anchor`, `before`, `onto`, `above`, - `below`) and in `window.panel.layout`'s `action` (`ui.set {panel: "library:swatches:loaded/<file name>"}` + shown in a `panel`, `library` or `panels` param, in a docking destination (`anchor`, `target`, `before`, `onto`, + `above`, `below`) and in `window.panel.layout`'s `action` (`ui.set {panel: "library:swatches:loaded/<file name>"}` reopens its panel), and the reply to such a request shows that id and file name wherever the library's id or path appears, an error included. Every reply under the read roots shows a hidden library's id as the id shown and its path as the file name in every string and key, the status text (`ui.status`), dialogs, the command palette query From 07423c22d6acbeb48832cf2fec5ab008d0f390d0 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 02:09:47 -0800 Subject: [PATCH 41/92] MCP: save and export replies keep the path the client gave under read roots Under automation read roots, a reply shows each path outside the read roots that the request sent by its file name. A save or export to a path the client gave inside the write roots then answered with the bare file name in `path`, where the save replies keep the given path. The `path` and `files` entries of the reply's result, a `command.batch` step's result included, that equal a path param the request gave inside the write roots now stay as given; every other occurrence is still shown by its file name, and loaded libraries outside the read roots keep their `loaded/<file name>` ids. --- crates/ui-egui/src/control.rs | 76 +++++++++++++++++++++++++- crates/ui-egui/src/tests/automation.rs | 39 +++++++++++++ docs/mcp.md | 5 +- 3 files changed, 117 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index ef747f8476..4ab3982607 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1086,7 +1086,8 @@ const DOCKING_ACTION: &str = "action"; /// such library's id and path as `ui.inspect` does ([`hide_library_ids`]) in every string and key, /// errors included, an error from a panic too: the libraries hidden before it ran and after it. The /// reply also shows each path outside the read root that the request sent by its file name, whether it -/// succeeded or failed ([`sent_paths`]). A reply with no `/`, `\` or `%` in it is not searched, since every hidden path +/// succeeded or failed ([`sent_paths`]), except where a `path` or `files` entry of its result names the +/// file the request wrote inside the write roots, as the request gave it. A reply with no `/`, `\` or `%` in it is not searched, since every hidden path /// holds a separator, written or escaped. A screenshot's reply is hidden when it is sent /// ([`hide_in_deferred_reply`]); a `ui.pointer` reply sent after the tool's edit holds no library id /// or path. @@ -1122,17 +1123,88 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques } /// Hides in `reply` the libraries hidden now and in `before`, and the paths outside the read root that -/// `sent`, the request's params, holds ([`handle`]). +/// `sent`, the request's params, holds ([`handle`]). The `path` and `files` entries of the reply's +/// `result`, at any depth, that name a file the request gave inside the write roots, the file it +/// wrote, stay as given ([`written_paths`]). fn hide_in_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value, before: Hiding) { if !holds_a_separator(reply) { return; } + let mut given = Vec::new(); + written_paths(sent, false, &mut given); + let kept = kept_written_paths(reply, &given); let mut hiding = unreadable_library_ids(app); hiding.merge(before); let mut paths = Vec::new(); sent_paths(sent, &mut paths); hiding.set_hidden(paths, cfg!(windows)); hide_library_ids(reply, &hiding); + restore_written_paths(reply, kept); +} + +/// The strings that `v`'s path params hold at any depth ([`vectorcraft_engine::file_access::is_path_key`]) +/// that name a file inside the write roots; `v` is a path param's value when `path_param` is set. +fn written_paths(v: &Value, path_param: bool, out: &mut Vec<String>) { + use vectorcraft_engine::file_access::{Access, current, is_path_key}; + match v { + Value::String(s) if path_param && !s.is_empty() && current().is_some_and(|r| r.check(s, Access::Write).is_ok()) => out.push(s.clone()), + Value::Array(items) => items.iter().for_each(|item| written_paths(item, path_param, out)), + Value::Object(fields) => { + for (key, item) in fields { + written_paths(item, is_path_key(key), out); + } + } + _ => {} + } +} + +/// The entries under `reply`'s `result`, at any depth (a `command.batch` step's result too), that a +/// `path` key or a `files` list holds and that are one of `given`: each entry's JSON pointer and text. +fn kept_written_paths(reply: &Value, given: &[String]) -> Vec<(String, String)> { + let mut kept = Vec::new(); + if !given.is_empty() { + kept_under(&reply["result"], "/result", given, &mut kept); + } + kept +} + +/// [`kept_written_paths`] for `v`, found at the JSON pointer `at`. +fn kept_under(v: &Value, at: &str, given: &[String], out: &mut Vec<(String, String)>) { + let is_given = |v: &Value| v.as_str().filter(|s| given.iter().any(|g| g == s)).map(str::to_string); + match v { + Value::Array(items) => { + for (i, item) in items.iter().enumerate() { + kept_under(item, &format!("{at}/{i}"), given, out); + } + } + Value::Object(fields) => { + for (key, item) in fields { + let here = format!("{at}/{}", key.replace('~', "~0").replace('/', "~1")); + match (key.as_str(), is_given(item), item.as_array()) { + ("path", Some(path), _) => out.push((here, path)), + ("files", _, Some(rows)) => { + for (i, row) in rows.iter().enumerate() { + match is_given(row) { + Some(path) => out.push((format!("{here}/{i}"), path)), + None => kept_under(row, &format!("{here}/{i}"), given, out), + } + } + } + _ => kept_under(item, &here, given, out), + } + } + } + _ => {} + } +} + +/// Writes back in `reply` the entries [`kept_written_paths`] took from it. +fn restore_written_paths(reply: &mut Value, kept: Vec<(String, String)>) { + for (at, path) in kept { + if let Some(slot) = reply.pointer_mut(&at) { + *slot = json!(path); + } + } } /// Hides in a reply sent after its request was handled (a screenshot's) what [`handle`] hides in a diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 68cb169c8c..073cefbd82 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -128,6 +128,45 @@ fn control_saves_leave_out_the_documents_file_outside_the_read_root() { let _ = std::fs::remove_dir_all(base); } +/// In one session with a library loaded from outside the read root: the library keeps its file-name +/// id, a reply that names its path shows the file name, a save without a path leaves out the +/// document's file, and a save or export to a path the client gave in the write root keeps that path +/// in `path` and `files`, also in a `command.batch` step's result. A refused path outside the write root is still shown by its file name. +#[test] +fn control_saves_keep_the_given_path_beside_a_hidden_library() { + let (mut app, tx, base) = confined("save-and-library"); + let library = s(base.join("outside/b.svg")); + app.ui.color_guide_limit = format!("loaded/{library}"); + app.run("file.new", json!({"width": 100, "height": 50})).unwrap(); + let own = s(base.join("out/doc.vectorcraft")); + app.run("file.saveAs", json!({"path": own})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let r = request(&mut app, &tx, "app.save", json!({})); + assert!(r["result"].get("path").is_none() && !r.to_string().contains(&s(base.join("out"))), "{r}"); + let r = request(&mut app, &tx, "app.save", json!({"path": own})); + assert_eq!(r["result"]["path"], json!(own), "{r}"); + let split = s(base.join("out/split.vectorcraft")); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "file.save", "params": {"path": split, "separateArtboards": true}})); + assert_eq!(r["result"]["path"], json!(split), "{r}"); + assert_eq!(r["result"]["files"][0], json!(split), "{r}"); + let batched = s(base.join("out/batched.vectorcraft")); + let steps = json!({"commands": [{"command": "document.save", "params": {"path": batched}}]}); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "command.batch", "params": steps})); + assert_eq!(r["result"]["results"][0]["path"], json!(batched), "{r}"); + let svg = s(base.join("out/a.svg")); + let r = request(&mut app, &tx, "app.export", json!({"path": svg, "wait": true})); + assert_eq!(r["result"]["path"], json!(svg), "{r}"); + app.ui.status = format!("from {library}"); + let r = request(&mut app, &tx, "test.status", json!({})); + assert_eq!(r["result"], "from b.svg", "{r}"); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}"); + let refused = s(base.join("outside/c.vectorcraft")); + let r = request(&mut app, &tx, "app.save", json!({"path": refused, "wait": true})); + assert!(r["error"].as_str().is_some_and(|e| e.contains("outside the write root") && !e.contains(&refused)), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// With Background Save and Background Export on, `app.save` and `app.export` with `wait: true` /// (MCP's `save_file` in the app) reply with the write root's refusal, and write a file inside it /// before they reply. Without `wait` the refusal reaches only the status bar. diff --git a/docs/mcp.md b/docs/mcp.md index 5cb8df4df8..03bb0b9665 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -178,7 +178,10 @@ the suite) keep the agent to the folders you give it: URL writes it, and on Windows in any case of its letters. A hidden id or path stays only where it begins another loaded library's whole id with no `.` or `..` part, so a readable library's id that begins with a hidden one stays. A reply also shows a path outside the read roots that the request sent by its file name, whether or not a library was loaded from it and whether the - request succeeded or failed, so that the reply reads the same either way. A path param (`path`, `file`, + request succeeded or failed, so that the reply reads the same either way. The one exception is the `path` + and `files` entries of a reply's result, a `command.batch` step's result included, that name the file the + request wrote inside the write roots at the path the request gave: they stay as + given, as for the save and export replies above. A path param (`path`, `file`, `destinationFolder` and the like) counts with a relative path too. The hidden libraries' ids and paths are matched before the paths the request sent, so a sent path written over part of a hidden library's path does not keep that path in the reply. A relative path is not replaced where it ends a longer path (`/work/data/icon.svg` From b4ffd4a97ec8554f07d8448ccf991e0fe3d61b46 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 06:24:28 -0800 Subject: [PATCH 42/92] MCP: a hidden path's file: URL shows as the file name, and rewritten keys keep every field Under automation read roots, a reply replaced only the path inside a `file:///outside/a.svg` URL, which left `file://a.svg`, a URL that names a host. The whole URL of an absolute path, with `localhost`, another host or no host, is now replaced with the file name, unless part of its scheme was already replaced. A reply key rewritten to read the same as another key of its object took that key's place and its field was lost; the rewritten key now takes ` (2)`, ` (3)` and so on, and a key that is not rewritten keeps its name. --- crates/ui-egui/src/control.rs | 46 ++++++++++++++++++++++++-- crates/ui-egui/src/control/tests.rs | 33 +++++++++++++++--- crates/ui-egui/src/tests/automation.rs | 4 +-- docs/mcp.md | 3 +- 4 files changed, 77 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 4ab3982607..f8022a3593 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -583,7 +583,8 @@ fn is_base64(s: &str) -> bool { /// Writes each `from` text of the rewrite's pairs as its `to` text in every string and key of `v`, as /// [`replace_text`] does. Encoded file data, a base64 string under a key of [`BASE64_FIELDS`], stays -/// as it is. +/// as it is. A rewritten key that reads the same as another key of its object takes ` (2)`, ` (3)` +/// and so on; a key that is not rewritten keeps its name. fn replace_in_value(v: &mut Value, r: &Rewrite) { match v { Value::String(s) => { @@ -593,13 +594,30 @@ fn replace_in_value(v: &mut Value, r: &Rewrite) { } Value::Array(items) => items.iter_mut().for_each(|item| replace_in_value(item, r)), Value::Object(fields) => { + // The keys that stay are written first, so a rewritten key that reads as one of them, or + // as another rewritten key, is the one numbered and no field is lost. + let mut rewritten = Vec::new(); for (key, mut item) in std::mem::take(fields) { if BASE64_FIELDS.contains(&key.as_str()) && item.as_str().is_some_and(is_base64) { fields.insert(key, item); continue; } replace_in_value(&mut item, r); - fields.insert(replace_text(&key, r).unwrap_or(key), item); + match replace_text(&key, r) { + Some(written) => rewritten.push((written, item)), + None => { + fields.insert(key, item); + } + } + } + for (written, item) in rewritten { + let mut key = written.clone(); + let mut n = 2; + while fields.contains_key(&key) { + key = format!("{written} ({n})"); + n += 1; + } + fields.insert(key, item); } } _ => {} @@ -681,6 +699,10 @@ impl<'a> Found<'a> { match begins_known_id(text, at, end, r) { Some(len) => self.taken.push(span(at, at + len)), None => { + // A `file:` URL of the path is replaced whole, so no `file://<name>` is left that + // would name a host, unless part of its scheme was taken already. + let url = span(url_start(text, at, from), end).0; + let start = if self.taken.iter().any(|&(a, b)| url < b && a < stop) { start } else { url }; self.taken.push((start, stop)); self.cuts.push((start, stop, to)); } @@ -689,6 +711,26 @@ impl<'a> Found<'a> { } } +/// Where a `file:` URL that holds `from`, an absolute path found at `at` in `text`, begins: `at` less +/// the `file:` before it, with `//` and a host (`localhost`, another host or none) between them, in any +/// case of its letters; before a drive, the host is followed by a `/` too. `at` when no such URL holds it. +fn url_start(text: &str, at: usize, from: &str) -> usize { + let drive = matches!(from.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()); + if !from.starts_with('/') && !drive { + return at; + } + let Some(before) = text.get(..at) else { return at }; + let Some(scheme) = before.to_ascii_lowercase().rfind("file:") else { return at }; + let rest = &before[scheme + "file:".len()..]; + let host = |h: &str| h.chars().all(|c| c.is_alphanumeric() || matches!(c, '.' | '-' | '_' | ':' | '[' | ']' | '%')); + let holds = if drive { + rest.strip_prefix("//").and_then(|r| r.strip_suffix('/')).is_some_and(host) + } else { + rest.is_empty() || rest.strip_prefix("//").is_some_and(host) + }; + if holds { scheme } else { at } +} + /// `text` with its `%XX` escapes decoded, and for each byte of the decoded text the range of `text` /// it was read from. A decoded byte that is not part of UTF-8 text is read as U+FFFD. fn decoded_with_spans(text: &str) -> Option<(String, Vec<(usize, usize)>)> { diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 0b19350f6f..babc710067 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -91,7 +91,8 @@ fn a_reply_keeps_a_hidden_path_only_inside_a_known_library_id() { } /// With matching in any case (as on Windows), a hidden path written in other letter cases is hidden -/// too; a path in a `file:` URL with `%XX` escapes is hidden on every platform. +/// too; a path in a `file:` URL with `%XX` escapes is hidden on every platform, the whole URL shown as +/// the file name. #[test] fn a_reply_hides_a_path_in_any_case_and_escaped() { let hidden = hiding(&[("loaded/C:/Libs/My Swatches/b.svg", "loaded/b.svg")], &[]); @@ -99,7 +100,7 @@ fn a_reply_hides_a_path_in_any_case_and_escaped() { let pairs = super::hidden_text(&hidden); let rewrite = super::Rewrite { pairs, hiding: &hidden, fold: true }; super::replace_in_value(&mut reply, &rewrite); - assert_eq!(reply["error"], "from b.svg and file:///b.svg", "{reply}"); + assert_eq!(reply["error"], "from b.svg and b.svg", "{reply}"); // Two libraries whose paths differ only in letter case keep their own ids. let two = hiding(&[("loaded//libs/b.svg", "loaded/b.svg"), ("loaded//libs/B.svg", "loaded/B.svg")], &[]); let mut reply = json!({"error": "x /libs/B.svg y /libs/b.svg z /LIBS/b.svg"}); @@ -112,11 +113,22 @@ fn a_reply_hides_a_path_in_any_case_and_escaped() { assert_eq!(reply["error"], "LOADED//LIBS/IN/A.SVG", "{reply}"); let mut reply = json!({"error": "file:///C:/Libs/My%20Swatches/b.svg"}); hide_under_a_root("escaped", &mut reply, &hidden); - assert_eq!(reply["error"], "file:///b.svg", "{reply}"); + assert_eq!(reply["error"], "b.svg", "{reply}"); let hidden = hiding(&[("loaded//libs/caf\u{e9}/b.svg", "loaded/b.svg")], &[]); let mut reply = json!({"error": "file:///libs/caf%C3%a9/b.svg and file:///libs%2Fcaf%c3%A9%2Fb.svg?next=%2Fdocs&x=%FF at 100%25"}); hide_under_a_root("escaped-mixed", &mut reply, &hidden); - assert_eq!(reply["error"], "file://b.svg and file://b.svg?next=%2Fdocs&x=%FF at 100%25", "{reply}"); + assert_eq!(reply["error"], "b.svg and b.svg?next=%2Fdocs&x=%FF at 100%25", "{reply}"); + // A `file:` URL without escapes is replaced whole too, with `localhost` or another host. + let mut reply = json!({"error": "file:///libs/caf\u{e9}/b.svg and FILE://localhost/libs/caf%C3%A9/b.svg"}); + hide_under_a_root("plain-url", &mut reply, &hidden); + assert_eq!(reply["error"], "b.svg and b.svg", "{reply}"); + let mut reply = json!({"error": "file://remote/libs/caf\u{e9}/b.svg; file:/libs/caf\u{e9}/b.svg; file: x/libs/caf\u{e9}/b.svg"}); + hide_under_a_root("other-host", &mut reply, &hidden); + assert_eq!(reply["error"], "b.svg; b.svg; file: xb.svg", "{reply}"); + let hidden = hiding(&[("loaded///host/share/c.svg", "loaded/c.svg")], &[]); + let mut reply = json!({"error": "file://host/share/c.svg, file:////host/share/c.svg, file://localhost//host/share/c.svg"}); + hide_under_a_root("host-url", &mut reply, &hidden); + assert_eq!(reply["error"], "c.svg, c.svg, c.svg", "{reply}"); } /// A path a failed request sent takes the place of a hidden library's id for the same path, written @@ -263,6 +275,19 @@ fn a_reply_replaces_each_hidden_text_once() { let mut reply = json!({"error": "file:///art/in%20work/a.svg"}); hide_under_a_root("escaped-readable", &mut reply, &hidden); assert_eq!(reply["error"], "file:///art/in%20work/a.svg", "{reply}"); + // A URL whose scheme another hidden path took already is cut at its path. + let hidden = hiding(&[("loaded//a", "loaded/a"), ("loaded/file", "loaded/file")], &[]); + let mut reply = json!({"error": "file:///a"}); + hide_under_a_root("scheme-taken", &mut reply, &hidden); + assert_eq!(reply["error"], "file://a", "{reply}"); + // Keys that come to read the same keep every field; a key that is not rewritten keeps its name. + let hidden = hiding(&[("loaded//out/a.svg", "loaded/a.svg")], &[]); + let mut reply = json!({"result": {"a.svg": 1, "file:///out/a.svg": 2, "/out/a.svg": 3}}); + hide_under_a_root("same-keys", &mut reply, &hidden); + assert_eq!(reply["result"]["a.svg"], 1, "{reply}"); + let mut values: Vec<_> = reply["result"].as_object().map(|o| o.values().filter_map(Value::as_u64).collect()).unwrap_or_default(); + values.sort_unstable(); + assert_eq!(values, [1, 2, 3], "{reply}"); } /// A library saved with another platform's absolute path is hidden, also when the working folder is diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 073cefbd82..0ed949edf5 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -786,7 +786,7 @@ fn a_screenshot_reply_hides_paths_like_any_reply() { } /// A reply whose only trace of a hidden library's path is a `file:` URL with every separator escaped -/// shows the file name. +/// shows the file name alone. #[test] fn a_reply_hides_a_path_with_escaped_separators() { let (mut app, tx, base) = confined("escaped-separators"); @@ -794,7 +794,7 @@ fn a_reply_hides_a_path_with_escaped_separators() { app.ui.color_guide_limit = format!("loaded/{path}"); app.ui.status = format!("from file:{}", path.replace('/', "%2F").replace('\\', "%5C")); let r = request(&mut app, &tx, "test.status", json!({})); - assert_eq!(r["result"], "from file:b.svg", "{r}"); + assert_eq!(r["result"], "from b.svg", "{r}"); let _ = std::fs::remove_dir_all(base); } diff --git a/docs/mcp.md b/docs/mcp.md index 03bb0b9665..faae559e61 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -175,7 +175,8 @@ the suite) keep the agent to the folders you give it: path as the file name in every string and key, the status text (`ui.status`), dialogs, the command palette query (`ui.palette_query`), a script's report (`scriptReport`), a screenshot's reply and the error from an internal fault included, for the libraries hidden before the request ran and after it. The path is also matched with `%XX` escapes as a `file:` - URL writes it, and on Windows in any case of its letters. A hidden id or path stays only where it begins + URL writes it, and on Windows in any case of its letters; a `file:` URL of an absolute path is shown as the file + name alone, without `file:` or its host. A hidden id or path stays only where it begins another loaded library's whole id with no `.` or `..` part, so a readable library's id that begins with a hidden one stays. A reply also shows a path outside the read roots that the request sent by its file name, whether or not a library was loaded from it and whether the request succeeded or failed, so that the reply reads the same either way. The one exception is the `path` From fd81eae9b582f15b6c5cbadc2caab748fee67024 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 07:20:37 -0800 Subject: [PATCH 43/92] Type: text.create and the type style commands refuse a size outside 0.1 to 1296 pt text.create clamped a size above 1296 pt to 1296 without an error, so a request for 2835 pt type returned 1296 pt type. text.createInPath, text.setStyle and text.setRangeStyle clamped the same way. Each now refuses a size outside 0.1 to 1296 pt with "`size` must be from 0.1 to 1296" and changes nothing. Both ends of the range are still taken. Follows #6643. --- crates/engine/src/cmd/addons/color/legend.rs | 2 +- crates/engine/src/cmd/addons/typetools.rs | 3 +- crates/engine/src/cmd/clipboard/flavours.rs | 2 +- crates/engine/src/cmd/create.rs | 12 +++--- crates/engine/src/cmd/textedit.rs | 11 ++--- crates/engine/src/cmd/typecmd.rs | 21 +++++++--- crates/engine/src/tests/textedit.rs | 44 ++++++++++++++++++++ docs/mcp.md | 4 +- 8 files changed, 77 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/addons/color/legend.rs b/crates/engine/src/cmd/addons/color/legend.rs index e2f38a7a17..e6942378be 100644 --- a/crates/engine/src/cmd/addons/color/legend.rs +++ b/crates/engine/src/cmd/addons/color/legend.rs @@ -137,7 +137,7 @@ fn legend(s: &mut Session, p: &Value) -> Result<Value> { let parent = st.target_parent()?; let text_size = (size / 7.0).clamp(4.0, 12.0); let pad = (size / 12.0).clamp(1.0, 8.0); - let style = super::super::super::create::new_type_style(s, &json!({ "size": text_size })); + let style = super::super::super::create::new_type_style(s, &json!({ "size": text_size }), C)?; let count = swatches.len(); // Each chip's fill links to its swatch, as paint.setFill {swatch} links art: editing a global or // spot swatch then recolours the legend too. diff --git a/crates/engine/src/cmd/addons/typetools.rs b/crates/engine/src/cmd/addons/typetools.rs index 537e6bfe75..10a00b1b45 100644 --- a/crates/engine/src/cmd/addons/typetools.rs +++ b/crates/engine/src/cmd/addons/typetools.rs @@ -139,7 +139,8 @@ fn text_tweaker(s: &mut Session, p: &Value) -> Result<Value> { let mut run = 0; for (i, at) in chars.into_iter().enumerate() { let random = if shape == "random" { unit(&mut rng) } else { 0.0 }; - let value = from + (to - from) * amount(shape, i, n, random); + // Rounding can carry a value a hair past the range's ends. + let value = (from + (to - from) * amount(shape, i, n, random)).clamp(lo, hi); let mut change = CharChange::parse(&json!({ key: value }), C, &setup)?; change.localize(&t, C)?; while ends.get(run).is_some_and(|&e| e <= at) { diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index 9463dd8cf1..e98dbd5619 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -491,7 +491,7 @@ fn import_text(s: &mut Session, p: &Value) -> Result<Value> { if text.trim().is_empty() { return Err(bad(C, "the text is empty")); } - let style = super::super::create::new_type_style(s, &Value::Null); + let style = super::super::create::new_type_style(s, &Value::Null, C)?; let system_layout = s.doc()?.doc.setup.system_layout; let layout = |t: &mut TextObject| { t.system_layout = system_layout; diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 37473cc59f..ebef729fd3 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -109,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> { "Create Text", [], None, - "{x, y, text, rotation?: deg clockwise about x, y (0: level), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (as text.areaOptions)}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", + "{x, y, text, rotation?: deg clockwise about x, y (0: level), vertical?: bool = false, size?: pt 0.1..1296 (outside it: an error, and no type is made), font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (as text.areaOptions)}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", has_doc, text_create ), @@ -739,7 +739,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> { check_numbers(a, &["width", "height"], "text.create")?; } let text = str_param(p, "text").unwrap_or(""); - let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p)); + let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p, "text.create")?); // Turned about the point (the first baseline's start, an area's top-left corner): type set // along a Constrain Angle. let rotation = if p.get("rotation").is_some() { f64_req(p, "rotation", "text.create")? } else { 0.0 }; @@ -848,11 +848,11 @@ pub(crate) fn new_area_fit(s: &Session, p: &Value, c: &str, t: &mut TextObject) /// The character style new type gets: `size`, `font`, `style` and `color` from `p` (an RGB /// `color` in a CMYK document as [`super::paint::new_color`] stores it), else the defaults, /// filled with the current fill unless that is None or white (black then), in the document's -/// language. -pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle { +/// language. A `size` outside [`super::typecmd::TYPE_SIZES`] is an error. +pub(crate) fn new_type_style(s: &Session, p: &Value, cmd: &str) -> Result<CharStyle> { let mut style = CharStyle::default(); if let Some(sz) = p.get("size").and_then(Value::as_f64) { - style.size = sz.clamp(0.1, 1296.0); + style.size = super::typecmd::given_type_size(sz, cmd)?; } if let Some(f) = str_param(p, "font") { style.font_family = f.to_string(); @@ -873,5 +873,5 @@ pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle { let chosen = |name: &str| vectorcraft_doc::setup::char_language(name).ok().flatten(); let document = s.doc().ok().and_then(|d| chosen(&d.doc.setup.language)); style.language = document.or_else(|| chosen(&s.prefs.hyphenation_language)).map(str::to_string); - style + Ok(style) } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index edfab850f5..d75ea13d6e 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_range_style ), @@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> { "Area / Path Type", [], None, - "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color?, keepModel? (as text.create), fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, bounds: {x, y, width, height} (as text.create), overflow?: [id] (the text doesn't fit; left out when it fits)}", + "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?: pt 0.1..1296 (outside it: an error), font?, style?, color?, keepModel? (as text.create), fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, bounds: {x, y, width, height} (as text.create), overflow?: [id] (the text doesn't fit; left out when it fits)}", has_doc, create_in_path ), @@ -713,7 +713,7 @@ impl CharChange { let c = Self { font: text_param(p, "font", cmd)?.map(str::to_string), style: text_param(p, "style", cmd)?.map(str::to_string), - size: num("size"), + size: num("size").map(|v| super::typecmd::given_type_size(v, cmd)).transpose()?, leading: auto_or_num(p, "leading", cmd)?, tracking: num("tracking"), kerning: match p.get("kerning") { @@ -757,9 +757,6 @@ impl CharChange { font_axes: font_axes_param(p, cmd)?, font_height_reference: font_height_reference_param(p, cmd)?, }; - if c.size.is_some_and(|v| v <= 0.0) { - return Err(bad(cmd, "size must be positive")); - } Ok(c) } @@ -1610,7 +1607,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> { check_numbers(p, &["size"], C)?; check_switches(p, &["vertical", "placeholder"], C)?; super::color_param(p, "color", C)?; - let style = super::create::new_type_style(s, p); + let style = super::create::new_type_style(s, p, C)?; let text = str_param(p, "text").unwrap_or("").to_string(); let start = match point_param(p, "at") { Some(at) if on_path => vectorcraft_geom::ArcPath::new(path).fraction_at(at).unwrap_or(0.0), diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 06368c6c7d..b2498a198c 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -138,7 +138,7 @@ pub fn specs() -> Vec<CommandSpec> { "Character", [], None, - "{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", …] OpenType, featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", …] OpenType, featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_style ), @@ -715,6 +715,20 @@ pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Node Ok(t) } +/// The type sizes a command takes, in points. +pub(crate) const TYPE_SIZES: (f64, f64) = (0.1, 1296.0); + +/// A type size `size` that a command was given: an error naming the range when it is outside +/// [`TYPE_SIZES`]. +pub(crate) fn given_type_size(size: f64, cmd: &str) -> Result<f64> { + let (lo, hi) = TYPE_SIZES; + if !(lo..=hi).contains(&size) { + let key = "size"; + return Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))); + } + Ok(size) +} + /// Clamp in the units shown to the user, then compensate the text's affine. Clamping /// the local result would make small sizes impossible after enlarging an object. pub(crate) fn local_type_value(t: &TextObject, value: f64, limits: (f64, f64), horizontal: bool, cmd: &str) -> Result<f64> { @@ -820,10 +834,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.setStyle"; let font = text_param(p, "font", C)?.map(str::to_string); let style = text_param(p, "style", C)?.map(str::to_string); - let size = length_given(p, "size", C)?; - if size.is_some_and(|v| v <= 0.0) { - return Err(bad(C, "size must be positive")); - } + let size = length_given(p, "size", C)?.map(|v| given_type_size(v, C)).transpose()?; let leading = match p.get("leading") { None | Some(Value::Null) => None, Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Some(None), diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index abb39c0d96..96a7575360 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -87,6 +87,50 @@ fn set_range_style_splits_runs() { assert!(s.execute("text.setRangeStyle", &json!({"id": id.0, "size": -1})).is_err()); } +/// `text.create`, `text.createInPath`, `text.setStyle` and `text.setRangeStyle` refuse a size +/// outside 0.1 to 1296 pt with an error naming the range, and change nothing; both ends are taken. +#[test] +fn a_type_size_outside_its_range_is_refused() { + let mut s = session(); + let message = "`size` must be from 0.1 to 1296"; + let state = |s: &Session| (s.doc().unwrap().doc.node_count(), s.doc().unwrap().history.undo.len()); + let before = state(&s); + for size in [2835.0, 1296.5, 0.05, 0.0, -1.0] { + let e = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Big", "size": size})).unwrap_err().to_string(); + assert!(e.contains(message), "{size}: {e}"); + } + assert_eq!(state(&s), before, "no type is made"); + let big = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Big", "size": 1296})).unwrap(); + let big = NodeId(big["id"].as_u64().unwrap()); + assert_eq!(obj(&s, big).runs[0].style.size, 1296.0); + let small = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "small", "size": 0.1})).unwrap(); + assert_eq!(obj(&s, NodeId(small["id"].as_u64().unwrap())).runs[0].style.size, 0.1); + let path = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"].clone(); + let before = state(&s); + let e = s.execute("text.createInPath", &json!({"path": path, "text": "In", "size": 2835})).unwrap_err().to_string(); + assert!(e.contains(message), "{e}"); + assert_eq!(state(&s), before); + s.execute("text.createInPath", &json!({"path": path, "text": "In", "size": 1296})).unwrap(); + let id = text(&mut s, "Hello"); + let before = state(&s); + for (cmd, params) in [ + ("text.setStyle", json!({"ids": [id.0], "size": 2835})), + ("text.setStyle", json!({"ids": [id.0], "size": "1300 pt"})), + ("text.setStyle", json!({"ids": [id.0], "size": 0.05})), + ("text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 2, "size": 2835})), + ("text.setRangeStyle", json!({"id": id.0, "size": 0})), + ] { + let e = s.execute(cmd, ¶ms).unwrap_err().to_string(); + assert!(e.contains(message), "{cmd} {params}: {e}"); + } + assert_eq!(state(&s), before, "nothing changes"); + assert_eq!(obj(&s, id).runs[0].style.size, 20.0); + s.execute("text.setStyle", &json!({"ids": [id.0], "size": 1296})).unwrap(); + assert_eq!(obj(&s, id).runs[0].style.size, 1296.0); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 1, "size": 0.1})).unwrap(); + assert_eq!(obj(&s, id).runs[0].style.size, 0.1); +} + /// `featureChanges` turns features on and off on top of each run's own and keeps the rest, in /// `text.setStyle` and `text.setRangeStyle`, in one undo step; an unknown tag is an error. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index fa22c66414..33ed414242 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1568,7 +1568,9 @@ wrong. A value that is not a number gets `` `dx` must be a number, not "10" ``: `` `size` must be a number, not true ``, and a `tracking` that is neither a number nor a number written as a string (`"20"`) with `` `tracking` must be a number, not false ``. It and `text.setRangeStyle` refuse a `font` or `style` that is not a string with `` `font` must be a string ``, and a `features` list with an entry that is not a string with -`features must be a list of tags`; `text.setStyle` refuses such a `justify` too. `type.ruby` refuses a `text`, +`features must be a list of tags`; `text.setStyle` refuses such a `justify` too. `text.create`, `text.createInPath`, +`text.setStyle` and `text.setRangeStyle` refuse a `size` outside 0.1 to 1,296 pt with +`` `size` must be from 0.1 to 1296 `` and change nothing. `type.ruby` refuses a `text`, `position` or `align` that is not a string, and `text.setPathHandle` refuses a `which` other than `in` or `out` with `` `which` must be one of in, out ``. Each leaves the document, the selection and the undo history as they were, also when the call gives another param it could apply. A null is read as a key left out. From 55a80ca480692aa397e087bc7cb9c9d6b38ef1a4 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 07:31:39 -0800 Subject: [PATCH 44/92] 3D and Materials: Expand as Wireframes looks up swatches for plug-ins after a fill's 3D effect A live-effect plug-in after a fill's or stroke's 3D effect (Extrude & Bevel, Inflate, Rotate or Revolve) that links its pieces to a swatch or pattern was run without the document, so its result was refused and the edges kept the shape before it. The document now goes to those effects, as it does for the effects after an object's 3D effect. --- crates/effects/src/revolve.rs | 7 +++--- crates/effects/src/solids/wireframe.rs | 13 ++++++----- crates/engine/src/cmd/wireframe/tests.rs | 28 ++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 8d05ab38ed..aae131dfdc 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -511,8 +511,9 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul /// instances, blends and clipping groups already resolved), in document coordinates: with the /// effect on the object, the surface in its top visible fill (else its stroke); with it on fills and strokes, one surface for each of /// them, reshaped by the geometry effects after each one's Revolve; on a group or layer, one for -/// each member, about one shared axis. -pub(crate) fn revolve_face_paths(n: &Node) -> Result<Vec<PathData>, String> { +/// each member, about one shared axis. The swatches and patterns named by a live-effect plug-in +/// after a fill's or stroke's Revolve are looked up in `doc`. +pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result<Vec<PathData>, String> { let faces = |path: &PathData, b: Rect, options: Revolve| -> Result<Vec<PathData>, String> { Ok(vectorcraft_three_d::revolve(path, b, options)?.iter().map(vectorcraft_three_d::Face::path).collect()) }; @@ -554,7 +555,7 @@ pub(crate) fn revolve_face_paths(n: &Node) -> Result<Vec<PathData>, String> { let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); let b = p.bounds().ok_or("Revolve requires a shape with at least two anchors")?; let surface = faces(&p, b, fx_options(fx))?; - out.extend(crate::solids::wireframe::reshaped(surface, item.effects().get(index + 1..).unwrap_or_default(), &ctx)); + out.extend(crate::solids::wireframe::reshaped(surface, item.effects().get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..ctx })); } } Ok(out) diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs index 5f2275d8eb..33f7f7f018 100644 --- a/crates/effects/src/solids/wireframe.rs +++ b/crates/effects/src/solids/wireframe.rs @@ -44,14 +44,14 @@ pub fn three_d_wireframe_in(doc: Option<&Document>, n: &Node) -> Result<Vec<Path let n = &*resolve(doc, n, label)?; // The builders' own error says why, when there is one. if crate::has_revolve(n) { - crate::revolve::revolve_face_paths(n)?; + crate::revolve::revolve_face_paths(doc, n)?; } else { - solid_face_paths(n)?; + solid_face_paths(doc, n)?; } return Err(format!("{label} draws the source art for this object instead of its 3D surface")); } let n = &*resolve(doc, n, label)?; - let faces = if revolve { crate::revolve::revolve_face_paths(n)? } else { solid_face_paths(n)? }; + let faces = if revolve { crate::revolve::revolve_face_paths(doc, n)? } else { solid_face_paths(doc, n)? }; let faces = after_geometry(doc, n, faces); let wire = edges(&faces)?; if wire.is_empty() { @@ -88,8 +88,9 @@ pub(crate) fn reshaped(faces: Vec<PathData>, later: &[Effect], ctx: &GeomContext /// The outline of every face [`super::solid_art`] draws for `n` (already resolved): with the effect /// on the object, its solids; with it on fills and strokes, the solid of each; on a group or layer, -/// the solids of its members, about one shared axis. -fn solid_face_paths(n: &Node) -> Result<Vec<PathData>, String> { +/// the solids of its members, about one shared axis. The swatches and patterns named by a live-effect +/// plug-in after a fill's or stroke's 3D effect are looked up in `doc`. +fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result<Vec<PathData>, String> { if is_container(n) { let (index, fx) = solid(&n.appearance.effects).ok_or("a 3D effect on a group's fill or stroke draws nothing")?; let mut shapes = members(n, label_of(&fx.id))?; @@ -110,7 +111,7 @@ fn solid_face_paths(n: &Node) -> Result<Vec<PathData>, String> { if let Some((index, fx)) = solid(item.effects()) { let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); let faces = facet_paths(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?; - out.extend(reshaped(faces, item.effects().get(index + 1..).unwrap_or_default(), &ctx)); + out.extend(reshaped(faces, item.effects().get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..ctx })); } } Ok(out) diff --git a/crates/engine/src/cmd/wireframe/tests.rs b/crates/engine/src/cmd/wireframe/tests.rs index ab51c81282..268fbcd6db 100644 --- a/crates/engine/src/cmd/wireframe/tests.rs +++ b/crates/engine/src/cmd/wireframe/tests.rs @@ -161,6 +161,34 @@ fn objects_without_a_3d_effect_are_left_and_none_at_all_is_an_error() { assert_eq!(s.doc().unwrap().doc.node(plain).unwrap(), &kept); } +/// A live-effect plug-in after a fill's 3D effect that links its pieces to one of the document's +/// swatches reshapes the edges, as it reshapes the drawn surface. +#[test] +fn a_painted_effect_after_a_fill_s_3d_effect_finds_the_document_s_swatches() { + use vectorcraft_geom::{PathData, Point, SubPath}; + let mut leaf = serde_json::to_value(vectorcraft_color::Paint::solid(vectorcraft_color::Color::from_hex("#00ff00").unwrap())).unwrap(); + leaf["swatch"] = json!("Leaf"); + let corners = [Point::new(300.0, 300.0), Point::new(320.0, 300.0), Point::new(320.0, 320.0), Point::new(300.0, 320.0)]; + let output = json!({"objects": [{"path": PathData::single(SubPath::polyline(&corners, true)), "fills": [leaf]}]}).to_string(); + let manifest = r#"{"id":"test.engine.wireleaf","name":"Test wireleaf","version":"1","kind":"effect","api":2,"paintOutput":true,"params":{}}"#; + let wasm = wat::parse_str(vectorcraft_plugins::wat::returning(manifest, &output)).unwrap(); + for effect in ["threeD.extrude", "threeD.revolve"] { + let (mut s, id) = setup(None); + s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "replace": true})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("swatch.new", &json!({"name": "Leaf", "color": "#00ff00", "global": true})).unwrap(); + let items = &s.doc().unwrap().doc.node(id).unwrap().appearance.items; + let fill = items.iter().position(|i| matches!(i, AppearanceItem::Fill(_))).unwrap(); + for e in [effect, "plugin.test.engine.wireleaf"] { + s.execute("effect.apply", &json!({"ids": [id.0], "effect": e, "item": fill, "params": {}})).unwrap(); + } + run(&mut s, json!({"ids": [id.0]})).unwrap_or_else(|e| panic!("{effect}: {e}")); + let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap(); + assert_eq!((b.x0, b.y0, b.x1, b.y1), (300.0, 300.0, 320.0, 320.0), "{effect}: the plug-in's square"); + assert_eq!(segments(&s, id), 4, "{effect}"); + } +} + #[test] fn bad_ids_are_errors() { let (mut s, _) = setup(Some("threeD.extrude")); From 5a8cb302595c1340099ebf97328fd927715800f9 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 07:44:35 -0800 Subject: [PATCH 45/92] 3D and Materials: Expand as Wireframes reshapes edges by the effects after the 3D effect drawn With two 3D effects on an object, the geometry effects after the first one reshaped the edges, even when the second one (Revolve, which is drawn first) built the faces. A geometry effect between the two then moved the profile and the edges both. The edges now take only the effects after the 3D effect that built them. --- crates/effects/src/solids/wireframe.rs | 11 +++++++---- crates/effects/src/solids/wireframe/tests.rs | 12 ++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs index 33f7f7f018..4f93b2f41a 100644 --- a/crates/effects/src/solids/wireframe.rs +++ b/crates/effects/src/solids/wireframe.rs @@ -52,7 +52,7 @@ pub fn three_d_wireframe_in(doc: Option<&Document>, n: &Node) -> Result<Vec<Path } let n = &*resolve(doc, n, label)?; let faces = if revolve { crate::revolve::revolve_face_paths(doc, n)? } else { solid_face_paths(doc, n)? }; - let faces = after_geometry(doc, n, faces); + let faces = after_geometry(doc, n, faces, revolve); let wire = edges(&faces)?; if wire.is_empty() { return Err(format!("{label} draws no faces for this object")); @@ -60,10 +60,13 @@ pub fn three_d_wireframe_in(doc: Option<&Document>, n: &Node) -> Result<Vec<Path Ok(wire) } -/// `faces` reshaped by the visible geometry effects after `n`'s own 3D effect ([`reshaped`]). -fn after_geometry(doc: Option<&Document>, n: &Node, faces: Vec<PathData>) -> Vec<PathData> { +/// `faces` reshaped by the visible geometry effects after the 3D effect of `n`'s own that drew them +/// ([`reshaped`]): its first visible Revolve when `revolve` is set, else its first visible Extrude & +/// Bevel, Inflate or Rotate. +fn after_geometry(doc: Option<&Document>, n: &Node, faces: Vec<PathData>, revolve: bool) -> Vec<PathData> { let fx = &n.appearance.effects; - let Some(index) = fx.iter().position(|e| e.visible && is_three_d(&e.id)) else { return faces }; + let drawn = if revolve { fx.iter().position(|e| e.visible && crate::revolve::is_revolve(&e.id)) } else { solid(fx).map(|(i, _)| i) }; + let Some(index) = drawn else { return faces }; reshaped(faces, fx.get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..GeomContext::of(n) }) } diff --git a/crates/effects/src/solids/wireframe/tests.rs b/crates/effects/src/solids/wireframe/tests.rs index 158e42d98f..4af9d7b526 100644 --- a/crates/effects/src/solids/wireframe/tests.rs +++ b/crates/effects/src/solids/wireframe/tests.rs @@ -192,6 +192,18 @@ fn a_geometry_effect_after_a_fill_s_3d_effect_reshapes_its_edges() { } } +/// With two 3D effects, a geometry effect between them reshapes the faces of the one drawn only +/// once: Revolve is drawn, so a Transform before it moves its profile and not its faces again. +#[test] +fn a_geometry_effect_before_the_drawn_3d_effect_is_not_applied_again() { + let mut n = solid_square(crate::REVOLVE, json!({})); + n.appearance.effects.insert(0, crate::new_effect("distort.transform", &json!({"moveH": 50.0})).unwrap()); + let alone = bounds(&three_d_wireframe_in(None, &n).unwrap()); + n.appearance.effects.insert(0, crate::new_effect(super::super::EXTRUDE, &json!({})).unwrap()); + let both = bounds(&three_d_wireframe_in(None, &n).unwrap()); + assert!((both.x0 - alone.x0).abs() < 1e-6 && (both.x1 - alone.x1).abs() < 1e-6, "{alone:?} {both:?}"); +} + #[test] fn an_object_without_a_visible_3d_effect_is_an_error() { let mut n = solid_square(super::super::EXTRUDE, json!({})); From 2defb4900209463c20734865301ffebef431cfcf Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 08:06:17 -0800 Subject: [PATCH 46/92] Font menu: one wide layout wherever it opens, with larger samples The font menu opened at its field's width, so from the Control bar's narrow Character field the filters wrapped one chip per line and family names were cut off after a few letters. - The filters keep the same four rows wherever the menu opens: the scripts; the star, Any Kind, Serif / Mincho, Sans Serif / Gothic and Rounded; Script / Brush, Monospaced and Decorative; Font Technology with Variable. - The menu opens at least wide enough for those rows and for a family name up to 220 points wide with room for its sample, up to 60% of the window, and stays in the window. A field wider than that keeps its width. - Where the row has room, the sample starts after the widest name instead of halfway along the row. It takes the row's whole height instead of 4 points less, so samples draw larger at every Font Preview Size. Requested by POGO. --- crates/ui-egui/src/font_menu.rs | 243 +++++++++++++++++++++----- crates/ui-egui/src/font_menu/tests.rs | 97 ++++++++++ crates/ui-egui/src/tests/font_menu.rs | 139 +++++++++++++++ docs/type-parity.md | 2 +- 4 files changed, 435 insertions(+), 46 deletions(-) diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index 0014047657..486cfd682f 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -6,9 +6,12 @@ //! back. The Glyphs panel and Find Font only pick a font ([`picked`]): nothing is previewed on the //! document. A star marks a favourite family, and the ★ filter lists only those. The Font //! Technology filter's Variable lists only variable font families. Preferences › Type › Enable -//! in-menu font previews and Font Preview Size turn the samples off or size the rows. Recently -//! used families appear first, in the order they were last applied, within the same search and -//! filters as the remaining families. In the Character and Properties panels and the Control bar, +//! in-menu font previews and Font Preview Size turn the samples off or size the rows. The menu lays +//! out its filters on the same four rows wherever it opens. However narrow its field, it opens at +//! least wide enough for those rows and for a family name up to 220 points wide with its sample +//! beside it, up to 60% of the window; each sample fills its row's height. Recently used families +//! appear first, in the order they were last applied, within the same search and filters as the +//! remaining families. In the Character and Properties panels and the Control bar, //! the mouse wheel turned over the closed field applies the next family (or previous one) to the //! selected text, one undo step per notch. The menu's order at the first turn stays fixed until the //! pointer leaves the field or the text selection changes; steps stop at its first and last family. @@ -132,10 +135,29 @@ impl<'a> MenuLook<'a> { const ROW: f32 = 24.0; /// Width of the star at the end of a family's row. const STAR: f32 = 18.0; -/// The share of a row the family's name takes; the sample fills the rest. +/// The least share of a row the family's name takes when the row is too narrow for the widest +/// name and [`SAMPLE_W`]; the sample fills the rest. const NAME_SHARE: f32 = 0.5; +/// Size of a family's name in its row. +const NAME_SIZE: f32 = 12.0; +/// The widest name the menu widens itself for. A longer name takes more of a row only in a menu +/// wider than that. +pub(crate) const NAME_MAX: f32 = 220.0; +/// Space between a family's name and its sample. +const NAME_GAP: f32 = 8.0; +/// How much wider than the sum of its characters' advances a name may lay out once shaped +/// (kerning, ligatures, contextual forms), as a share of that sum. +const SHAPING: f32 = 0.1; +/// The room the menu keeps for a sample beside the widest name. +const SAMPLE_W: f32 = 150.0; +/// The greatest share of the window's width the menu takes. +const WINDOW_SHARE: f32 = 0.6; +/// Size of the filters' labels. +const FILTER_SIZE: f32 = 11.0; /// Width of the disclosure triangle of a family with several styles. const DISCLOSURE: f32 = 14.0; +/// How far a family's style rows are indented past the triangle. +const STYLE_INDENT: f32 = 12.0; /// The list's greatest height, under the search field and filters. const LIST_HEIGHT: f32 = 600.0; /// Samples rendered (or asked for) per frame, across every menu, so a long list doesn't stall the UI. @@ -289,6 +311,8 @@ struct MenuState { rows_key: Option<RowsKey>, /// The height of the search field and filters last frame. header: f32, + /// The width of the widest row of filters without wrapping, last frame. + filters: f32, /// The families the wheel steps through, listed at its first notch over the closed field and /// kept for that text selection while the pointer stays on it: each step makes its family a recent one, which would /// otherwise move it in the order. @@ -321,7 +345,11 @@ pub fn font_menu( // of families. let (field, shown) = widgets::combo_menu_field(ui, &id, current, width, |menu| { menu.height(height).show_rows_with_header( - |ui| header(ui, state_id, state, current, look), + |ui| { + // Wider than a narrow field, so the filters keep their rows and long names show. + ui.set_min_width(menu_width(ui, state, look)); + header(ui, state_id, state, current, look) + }, look.row, |h| h.rows.len(), |ui, range, h| rows(ui, range, h, sample, look), @@ -957,42 +985,7 @@ fn header<'s>(ui: &mut Ui, state_id: egui::Id, st: &'s mut MenuState, current: & ui.memory_mut(|m| m.request_focus(field.with("edit"))); } let query = widgets::search_field(ui, field, tl!("Search")).to_lowercase(); - // The filters: script, then kind, then font technology. - let mut refilter = false; - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = vec2(4.0, 2.0); - for i in 0..SCRIPTS { - if ui.selectable_label(st.script == i, egui::RichText::new(script_label(i)).size(11.0)).clicked() { - st.script = i; - refilter = true; - } - } - }); - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = vec2(4.0, 2.0); - if ui.selectable_label(st.favorites_only, egui::RichText::new("★").size(11.0)).on_hover_text(tl!("Favorites")).clicked() { - st.favorites_only = !st.favorites_only; - refilter = true; - } - if ui.selectable_label(st.class.is_none(), egui::RichText::new(tl!("Any Kind")).size(11.0)).clicked() { - st.class = None; - refilter = true; - } - for c in FontClass::ALL { - if ui.selectable_label(st.class == Some(c), egui::RichText::new(class_label(c)).size(11.0)).clicked() { - st.class = if st.class == Some(c) { None } else { Some(c) }; - refilter = true; - } - } - }); - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = vec2(4.0, 2.0); - ui.label(egui::RichText::new(tl!("Font Technology")).size(11.0).color(t.text_dim)); - if ui.selectable_label(st.variable_only, egui::RichText::new(variable_label()).size(11.0)).clicked() { - st.variable_only = !st.variable_only; - refilter = true; - } - }); + let refilter = filters(ui, st, &t); let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); let db = vectorcraft_text::FontDb::global(); // The current family by its own name (a document may name it in Japanese: ヒラギノ角ゴシック), @@ -1064,6 +1057,149 @@ fn header<'s>(ui: &mut Ui, state_id: egui::Id, st: &'s mut MenuState, current: & Listed { st, rows, current: current.to_string(), enter, keyed, opening } } +/// One of the filters' chips. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Chip { + /// A script ([`SCRIPTS`]). + Script(usize), + Favorites, + AnyKind, + Class(FontClass), + /// The Font Technology label, which filters nothing. + Technology, + Variable, +} + +/// The filters, row by row, the same wherever the menu opens: the scripts; favorites, any kind +/// and the first three kinds; the other kinds; font technology. +fn filter_rows() -> [Vec<Chip>; 4] { + let (first, rest) = FontClass::ALL.split_at(3); + [ + (0..SCRIPTS).map(Chip::Script).collect(), + [Chip::Favorites, Chip::AnyKind].into_iter().chain(first.iter().copied().map(Chip::Class)).collect(), + rest.iter().copied().map(Chip::Class).collect(), + vec![Chip::Technology, Chip::Variable], + ] +} + +/// The filters on their rows ([`filter_rows`]): a row wraps only where the menu is narrower than +/// it. Records in `st` the width of the widest row unwrapped; whether a filter changed. +fn filters(ui: &mut Ui, st: &mut MenuState, t: &Tokens) -> bool { + let mut refilter = false; + let mut widest = 0.0_f32; + for row in filter_rows() { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(4.0, 2.0); + let gap = ui.spacing().item_spacing.x; + let text = |s: &str| egui::RichText::new(s).size(FILTER_SIZE); + let mut width = -gap; + for chip in row { + let resp = match chip { + Chip::Script(i) => ui.selectable_label(st.script == i, text(script_label(i))), + Chip::Favorites => ui.selectable_label(st.favorites_only, text("★")).on_hover_text(tl!("Favorites")), + Chip::AnyKind => ui.selectable_label(st.class.is_none(), text(tl!("Any Kind"))), + Chip::Class(c) => ui.selectable_label(st.class == Some(c), text(class_label(c))), + Chip::Technology => ui.label(text(tl!("Font Technology")).color(t.text_dim)), + Chip::Variable => ui.selectable_label(st.variable_only, text(variable_label())), + }; + width += resp.rect.width() + gap; + if !resp.clicked() { + continue; + } + match chip { + Chip::Script(i) => st.script = i, + Chip::Favorites => st.favorites_only = !st.favorites_only, + Chip::AnyKind => st.class = None, + Chip::Class(c) => st.class = if st.class == Some(c) { None } else { Some(c) }, + Chip::Technology => continue, + Chip::Variable => st.variable_only = !st.variable_only, + } + refilter = true; + } + widest = widest.max(width); + }); + } + st.filters = widest; + refilter +} + +/// The width the open menu takes at least: the widest row of filters last frame, and the widest +/// name up to [`NAME_MAX`] with room for its sample, the star and the scroll bar beside it, within +/// [`WINDOW_SHARE`] of the window. The menu is never narrower than its field. +fn menu_width(ui: &Ui, st: &MenuState, look: MenuLook) -> f32 { + let sample = if look.samples { SAMPLE_W } else { 0.0 }; + let names = name_width(ui, look.english_names, &st.rows).min(NAME_MAX); + let row = DISCLOSURE + names + NAME_GAP + sample + STAR + 2.0 + widgets::list_scroll_style().allocated_width(); + // Rounding in the chips' widths must not wrap the widest row. + let filters = if st.filters > 0.0 { st.filters.ceil() + 1.0 } else { 0.0 }; + filters.max(row).min(ui.ctx().content_rect().width() * WINDOW_SHARE) +} + +/// The width of the widest name the menu lists after the disclosure triangle: the family names as +/// Show Font Names in English shows them, and the style names among `rows` after their indent. +fn name_width(ui: &Ui, english: bool, rows: &[Row]) -> f32 { + let font = egui::FontId::proportional(NAME_SIZE); + let measure = |name: &str| ui.painter().layout_no_wrap(name.to_string(), font.clone(), Color32::WHITE).size().x; + let styles = rows.iter().filter_map(Row::style).map(|s| STYLE_INDENT + measure(s)).fold(0.0, f32::max); + family_name_width(ui, english).max(styles) +} + +/// What the widest family name was measured for: the font list's generation, Show Font Names in +/// English, and the number of fonts the interface draws with, which grows as faces for other +/// scripts are added to it. +fn family_name_key(ui: &Ui, english: bool) -> (u64, bool, usize) { + let faces = ui.ctx().fonts(|f| f.definitions().font_data.len()); + (vectorcraft_text::FontDb::global().generation(), english, faces) +} + +/// The id under which the widest family name's width is kept with its [`family_name_key`]. +fn family_name_id() -> egui::Id { + egui::Id::unique("font-menu-name-width") +} + +/// The width of the widest family name the menu lists, labeled as Show Font Names in English +/// (`english`) labels it; worked out again only when its [`family_name_key`] changes. +fn family_name_width(ui: &Ui, english: bool) -> f32 { + let key = family_name_key(ui, english); + if let Some((k, w)) = ui.data(|d| d.get_temp::<((u64, bool, usize), f32)>(family_name_id())) + && k == key + { + return w; + } + let db = vectorcraft_text::FontDb::global(); + let w = widest_label(ui, db.menu_family_list().iter().map(|f| db.family_display_name(f, english)).collect()); + ui.data_mut(|d| d.insert_temp(family_name_id(), (key, w))); + w +} + +/// The width of the widest of `labels` laid out at the names' size. Each label's characters' +/// advances are added up, and the labels are laid out widest sum first, until no sum left could +/// reach the widest layout, allowing [`SHAPING`] for shaping. +fn widest_label(ui: &Ui, labels: Vec<String>) -> f32 { + let font = egui::FontId::proportional(NAME_SIZE); + let mut sums: Vec<(f32, String)> = + ui.ctx().fonts_mut(|f| labels.into_iter().map(|l| (l.chars().map(|c| f.glyph_width(&font, c)).sum(), l)).collect()); + sums.sort_by(|a, b| b.0.total_cmp(&a.0)); + let mut w = 0.0_f32; + for (sum, label) in sums { + if sum * (1.0 + SHAPING) + 1.0 <= w { + break; + } + w = w.max(ui.painter().layout_no_wrap(label, font.clone(), Color32::WHITE).size().x); + } + w +} + +/// Where a row `width` wide puts its sample, after the name column: room for the widest name +/// (`widest`) and a gap, as far as that leaves [`SAMPLE_W`] and the star for the sample or +/// [`NAME_SHARE`] of the row for the name. Without samples the name runs to the star. +fn name_column(width: f32, widest: f32, samples: bool) -> f32 { + if !samples { + return width - STAR; + } + (DISCLOSURE + widest + NAME_GAP).min((width - STAR - 2.0 - SAMPLE_W).max(width * NAME_SHARE)) +} + /// The disclosure triangle of a family with several styles; whether the pointer is on it. fn disclosure(p: &egui::Painter, resp: &egui::Response, tri: egui::Rect, open: bool, t: &Tokens) -> bool { p.text(tri.center(), egui::Align2::CENTER_CENTER, if open { "▾" } else { "▸" }, egui::FontId::proportional(11.0), t.text_dim); @@ -1101,6 +1237,7 @@ fn rows(ui: &mut Ui, range: std::ops::Range<usize>, h: Listed<'_>, sample: Optio let t = Tokens::get(ui.ctx()); let db = vectorcraft_text::FontDb::global(); let favorite = |f: &str| look.favorites.iter().any(|x| x.eq_ignore_ascii_case(f)); + let widest = name_width(ui, look.english_names, &rows); if rows.is_empty() { // A filter or a font rescan can remove the row whose font was previewed. // Do not leave that font applied while the list has no candidate to highlight. @@ -1166,8 +1303,8 @@ fn rows(ui: &mut Ui, range: std::ops::Range<usize>, h: Listed<'_>, sample: Optio starred = Some(f.clone()); } } - let indent = if row.style().is_some() { DISCLOSURE + 12.0 } else { DISCLOSURE }; - let name_w = rect.width() * NAME_SHARE; + let indent = if row.style().is_some() { DISCLOSURE + STYLE_INDENT } else { DISCLOSURE }; + let name_w = name_column(rect.width(), widest, look.samples); let name_rect = egui::Rect::from_min_max(rect.min + vec2(indent, 0.0), egui::pos2(rect.min.x + name_w - 4.0, rect.max.y)); // Family rows follow Show Font Names in English; style rows keep the style name (#394). let name = match row { @@ -1175,8 +1312,9 @@ fn rows(ui: &mut Ui, range: std::ops::Range<usize>, h: Listed<'_>, sample: Optio Row::Family(f) => db.family_display_name(f, look.english_names), Row::Heading(_) => continue, }; - ui.painter_at(name_rect).text(name_rect.left_center(), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), t.text); - let sample_rect = egui::Rect::from_min_max(rect.min + vec2(name_w, 2.0), rect.max - vec2(STAR + 2.0, 2.0)); + ui.painter_at(name_rect).text(name_rect.left_center(), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(NAME_SIZE), t.text); + // The sample takes the row's whole height. + let sample_rect = egui::Rect::from_min_max(rect.min + vec2(name_w, 0.0), rect.max - vec2(STAR + 2.0, 0.0)); if !look.samples { } else if let Some(tex) = sample_texture(ui.ctx(), family, row.style(), sample, sample_rect.height()) { paint_sample(ui, sample_rect, &tex, t.text); @@ -1376,6 +1514,21 @@ pub(crate) fn render_sample_for_test(family: &str, text: &str, px: u32) -> Optio render_sample(family, "Regular", text, px) } +/// Waits for the samples rendering on this thread's behalf and keeps them, so the next frame +/// draws them. +#[cfg(all(test, not(target_arch = "wasm32")))] +pub(crate) fn wait_for_samples(ctx: &egui::Context) { + SAMPLES.with(|s| { + let mut s = s.borrow_mut(); + let pending: Vec<_> = s.pending.drain().collect(); + for (key, rx) in pending { + let tex = + rx.recv().ok().flatten().map(|img| ctx.load_texture(format!("font-sample-{}-{}", key.0, key.3), img, egui::TextureOptions::LINEAR)); + s.ready.insert(key, tex); + } + }); +} + // ---------- applying a pick ---------- /// Is a font previewed on the text (an interaction is open for it)? diff --git a/crates/ui-egui/src/font_menu/tests.rs b/crates/ui-egui/src/font_menu/tests.rs index 259408711c..69063e8e87 100644 --- a/crates/ui-egui/src/font_menu/tests.rs +++ b/crates/ui-egui/src/font_menu/tests.rs @@ -58,6 +58,103 @@ fn the_scroll_bar_leaves_the_stars_clear() { } } +/// In a wide row the sample starts after the widest name and a gap, not halfway along, even for a +/// name wider than the menu widens itself for; the sample keeps its room, and a row too narrow for +/// both gives the name half of it. Without samples the name runs to the star. +#[test] +fn the_sample_starts_after_the_widest_name() { + use super::{DISCLOSURE, NAME_GAP, NAME_MAX, SAMPLE_W, STAR, name_column}; + assert_eq!(name_column(500.0, 100.0, true), DISCLOSURE + 100.0 + NAME_GAP); + assert_eq!(name_column(600.0, NAME_MAX + 80.0, true), DISCLOSURE + NAME_MAX + 80.0 + NAME_GAP); + assert_eq!(name_column(600.0, 1000.0, true), 600.0 - STAR - 2.0 - SAMPLE_W); + assert_eq!(name_column(200.0, 100.0, true), 100.0); + assert_eq!(name_column(500.0, 100.0, false), 500.0 - STAR); +} + +/// The widest label is the widest one laid out, though it has fewer characters than others, or +/// characters that are narrow on their own. +#[test] +fn the_widest_label_is_the_widest_laid_out() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut labels: Vec<String> = ["WWWWWWWW", "Mm", "", "ffi ffl fi fl", "Source Serif 4 Display Semibold"].map(str::to_string).into(); + labels.push("i".repeat(40)); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let font = egui::FontId::proportional(super::NAME_SIZE); + let laid_out = |l: &String| ui.painter().layout_no_wrap(l.clone(), font.clone(), egui::Color32::WHITE).size().x; + let widest = labels.iter().map(laid_out).fold(0.0, f32::max); + assert_eq!(super::widest_label(ui, labels.clone()), widest); + for l in &labels { + assert_eq!(super::widest_label(ui, vec![l.clone()]), laid_out(l), "{l}"); + } + assert_eq!(super::widest_label(ui, vec![]), 0.0); + }); + out.textures_delta.clear(); +} + +/// The widest family name is measured again when the interface's fonts change, as they do when +/// faces for another script are added to it, not only when the font list does. +#[test] +fn the_widest_name_is_measured_again_when_the_interface_fonts_change() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let frame = |f: &mut dyn FnMut(&mut egui::Ui)| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| f(ui)); + out.textures_delta.clear(); + }; + // A width no name has, kept as if measured for the fonts as they are. + frame(&mut |ui| { + let key = super::family_name_key(ui, true); + ui.data_mut(|d| d.insert_temp(super::family_name_id(), (key, -1.0_f32))); + }); + frame(&mut |ui| { + let data = ui.ctx().fonts(|f| f.definitions().font_data.values().next().cloned()).expect("an interface font"); + ui.ctx().add_font(egui::epaint::text::FontInsert { name: "font-menu-test-face".into(), data: (*data).clone(), families: vec![] }); + }); + frame(&mut |ui| assert!(super::family_name_width(ui, true) >= 0.0, "measured again")); +} + +/// Without a row of filters drawn yet, the menu still makes room for the widest name, its sample, +/// the star and the scroll bar; without samples it is the sample's room narrower. +#[test] +fn the_menu_makes_room_for_the_widest_name_and_its_sample() { + use super::{DISCLOSURE, NAME_GAP, NAME_MAX, SAMPLE_W, STAR}; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 900.0))), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + let st = super::MenuState::default(); + let look = super::MenuLook::default(); + let names = super::name_width(ui, look.english_names, &st.rows).min(NAME_MAX); + assert!(names > 0.0, "a family is listed"); + let width = super::menu_width(ui, &st, look); + assert!(width >= DISCLOSURE + names + NAME_GAP + SAMPLE_W + STAR, "the menu is {width} wide for a name {names} wide"); + let plain = super::menu_width(ui, &st, super::MenuLook { samples: false, ..look }); + assert!((width - plain - SAMPLE_W).abs() < 0.01, "{width} with samples, {plain} without"); + }); + out.textures_delta.clear(); +} + +/// The style names of the families expanded in the listed rows count toward the names' width; +/// those of a family expanded earlier but not listed now do not. +#[test] +fn only_listed_style_names_widen_the_names() { + use super::{Row, STYLE_INDENT}; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let long = format!("SemiCondensed ExtraLight Italic {}", "W".repeat(80)); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let families = super::name_width(ui, true, &[]); + let font = egui::FontId::proportional(super::NAME_SIZE); + let style = STYLE_INDENT + ui.painter().layout_no_wrap(long.clone(), font, egui::Color32::WHITE).size().x; + assert!(style > families, "the style name is wider than every family name"); + let listed = [Row::Family("A".into()), Row::Style("A".into(), long.clone())]; + assert_eq!(super::name_width(ui, true, &listed), style); + assert!(super::name_width(ui, true, &listed[..1]) < style, "a style not listed is not measured"); + }); + out.textures_delta.clear(); +} + use super::{Row, remap_highlight}; fn family(name: &str) -> Row { diff --git a/crates/ui-egui/src/tests/font_menu.rs b/crates/ui-egui/src/tests/font_menu.rs index ea79c1d5cc..109122308f 100644 --- a/crates/ui-egui/src/tests/font_menu.rs +++ b/crates/ui-egui/src/tests/font_menu.rs @@ -959,3 +959,142 @@ fn a_preview_stays_put_while_the_menu_is_still() { assert_eq!(picked, Some(FontPick::EndPreview)); assert_eq!(font(&app), "Inter"); } + +/// What an open font menu drew: each text with its rectangle and clip rectangle, the rectangles of +/// the samples, and the menu's rectangle. +struct Drawn { + texts: Vec<(String, egui::Rect, egui::Rect)>, + samples: Vec<egui::Rect>, + menu: egui::Rect, +} + +impl Drawn { + fn text(&self, label: &str) -> egui::Rect { + self.texts.iter().find(|(t, ..)| t == label).map(|(_, r, _)| *r).unwrap_or_else(|| panic!("{label} is drawn")) + } + + /// The filters' rows, each by the centers of its first and last chips, in the UI's language. + fn filter_rows(&self) -> Vec<(f32, f32)> { + use crate::i18n::{current, t, tr_ctx}; + let variable = tr_ctx(current(), "font-technology", "Variable"); + [(t("All Scripts"), t("Thai")), ("★", t("Rounded")), (t("Script / Brush"), t("Decorative")), (t("Font Technology"), variable)] + .map(|(first, last)| (self.text(first).center().y, self.text(last).center().y)) + .to_vec() + } + + /// The filters lie on their four rows, none wrapped. + fn assert_filter_rows(&self) { + let rows = self.filter_rows(); + for (i, (first, last)) in rows.iter().enumerate() { + assert!((first - last).abs() < 1.0, "filter row {i} wraps: its first chip at {first}, its last at {last}"); + } + assert!(rows.windows(2).all(|w| w[1].0 > w[0].0 + 4.0), "four rows of filters, in order: {rows:?}"); + } +} + +/// Opens the font menu of a field `width` wide whose left edge is `left` points into a window of +/// size `screen`, draws it until it settles and its samples are rendered, and returns what the +/// last frame drew. +fn open_menu(id: &str, left: f32, width: f32, screen: egui::Vec2) -> Drawn { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let field = egui::Rect::from_min_size(egui::pos2(left, 10.0), egui::vec2(width, 30.0)); + let frame = |events: Vec<egui::Event>| { + let input = egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, screen)), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + ui.scope_builder(egui::UiBuilder::new().max_rect(field), |ui| { + font_menu(ui, id, "Inter", width, None, MenuLook::default()); + }); + }); + out.textures_delta.clear(); + out.shapes + }; + let click = field.min + egui::vec2(10.0, 10.0); + let button = |pressed| egui::Event::PointerButton { pos: click, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(vec![]); + frame(vec![egui::Event::PointerMoved(click), button(true)]); + frame(vec![button(false)]); + for _ in 0..5 { + frame(vec![]); + } + crate::font_menu::wait_for_samples(&ctx); + let shapes = frame(vec![]); + let mut texts = vec![]; + let mut samples = vec![]; + for s in &shapes { + match &s.shape { + egui::Shape::Text(t) => texts.push((t.galley.text().to_string(), egui::Rect::from_min_size(t.pos, t.galley.size()), s.clip_rect)), + egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default() => samples.push(m.calc_bounds()), + _ => {} + } + } + let mut drawn = Drawn { texts, samples, menu: egui::Rect::NOTHING }; + let filter = drawn.text(crate::i18n::t("Any Kind")).center(); + drawn.menu = ctx.layer_id_at(filter).and_then(|l| ctx.memory(|m| m.area_rect(l.id))).expect("the menu is open"); + drawn +} + +/// A narrow field, such as the Control bar's, opens a menu wide enough for the filters on their +/// rows and for the families' names, each drawn whole beside its sample. +#[test] +fn a_narrow_field_opens_a_menu_wide_enough_for_the_filters_and_names() { + let field = 115.0; + let d = open_menu("test-narrow", 20.0, field, egui::vec2(1200.0, 800.0)); + d.assert_filter_rows(); + assert!(d.menu.width() > 2.0 * field, "the menu is {} wide", d.menu.width()); + let families = FontDb::global().menu_family_list(); + // The field shows the current family too: only the names in the menu's rows. + let names: Vec<_> = d.texts.iter().filter(|(t, r, _)| r.top() > d.text("Variable").bottom() && families.iter().any(|f| f == t)).collect(); + assert!(names.iter().any(|(_, r, _)| r.width() > field / 2.0), "a name wider than half the field is listed: {names:?}"); + // The menu widens itself for names up to its limit; a longer name is cut. + for (name, r, clip) in names.iter().filter(|(_, r, _)| r.width() <= crate::font_menu::NAME_MAX) { + assert!(r.right() <= clip.right() + 0.5, "{name} ends at {} and is cut at {}", r.right(), clip.right()); + } +} + +/// In every UI language the filters keep their four rows unwrapped when the window has room for +/// them: the longest translated labels set the menu's width. +#[test] +fn the_filter_rows_stay_whole_in_every_language() { + for lang in crate::i18n::Lang::all() { + crate::i18n::set_current(lang); + let d = open_menu(&format!("test-lang-{}", lang.code()), 20.0, 115.0, egui::vec2(2400.0, 900.0)); + let rows = d.filter_rows(); + for (i, (first, last)) in rows.iter().enumerate() { + assert!((first - last).abs() < 1.0, "{}: filter row {i} wraps in a menu {} wide", lang.code(), d.menu.width()); + } + } + crate::i18n::set_current(crate::i18n::Lang::EN); +} + +/// A menu opened near the window's right edge is wider than its field and stays in the window. +#[test] +fn a_menu_wider_than_its_field_stays_in_the_window() { + let screen = egui::vec2(700.0, 800.0); + let d = open_menu("test-edge", 600.0, 90.0, screen); + d.assert_filter_rows(); + assert!(d.menu.width() > 200.0, "the menu is {} wide", d.menu.width()); + assert!(d.menu.left() >= -0.5 && d.menu.right() <= screen.x + 0.5, "the menu at {:?} leaves the window", d.menu); +} + +/// A docked panel's field is as wide as the panel: its menu takes that width, with the filters on +/// the same rows as a narrow field's menu. +#[test] +fn a_wide_fields_menu_takes_its_width_with_the_same_filter_rows() { + let field = 480.0; + let d = open_menu("test-docked", 20.0, field, egui::vec2(1200.0, 800.0)); + d.assert_filter_rows(); + // The field is drawn 4 points narrower than its width; the menu's frame adds its margins. + assert!(d.menu.width() >= field - 4.0 && d.menu.width() < field + 40.0, "the menu is {} wide", d.menu.width()); +} + +/// Each sample takes its row's whole height. +#[test] +fn samples_fill_their_rows_height() { + let d = open_menu("test-samples", 20.0, 260.0, egui::vec2(1200.0, 800.0)); + assert!(!d.samples.is_empty(), "samples are drawn"); + let row = MenuLook::default().row; + for s in &d.samples { + assert!((s.height() - row).abs() < 0.5, "a sample {} high in a row {row} high", s.height()); + } +} diff --git a/docs/type-parity.md b/docs/type-parity.md index 30547d5a17..c67e915d56 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -31,7 +31,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. | | Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. | | OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table, one of which a double-click draws for that character (`text.setGlyphAlternate`); the glyph-cell pop-up of alternates is not there yet | -| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux); Select Text by Font takes a scope: the document, chosen artboards or chosen layers; Find Font's Find In offers Document, Artboard (the active one) and Layers (the selected ones, else the current one) (#5529, a VectorCraft addition); Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972). | +| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux); Select Text by Font takes a scope: the document, chosen artboards or chosen layers; Find Font's Find In offers Document, Artboard (the active one) and Layers (the selected ones, else the current one) (#5529, a VectorCraft addition); Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972); wherever the font menu opens, its filters keep the same four rows, and it opens at least wide enough for them and for a family name up to 220 points wide with its sample (up to 60% of the window), each sample filling its row's height (a VectorCraft layout). | | Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG | | Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending | | Variable fonts | done | named instances as styles; Character panel › Variable Font sets each axis (`fontAxes`); the font menu's Font Technology › Variable lists only variable font families, with the script, kind and ★ filters ([Find, apply, and adjust variable fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-apply-and-adjust-variable-fonts.html)); a family with a variable file beside static ones counts as variable. Evidence: `font_menu::tests::the_variable_filter_lists_only_variable_families`, `tests_axes::a_family_with_variation_axes_is_variable`, `tests_sysfonts::a_family_with_a_variable_file_beside_static_ones_is_variable` | From 45dd24d905710ecc7c8664c158f8c78a764ac1e6 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 08:10:15 -0800 Subject: [PATCH 47/92] .ai save: a stroke the object's art style keeps no longer warns A path with an effect, or a path linked to a graphic style, can have a solid stroke a plain line can't draw, such as one with a width profile. When the reader reads the path back from an art style, the path is written with that style, and the style holds the stroke. The path's drawn look still writes the stroke as a filled outline, and the save warned that such strokes are written as filled outlines. The warning is now left out while a drawn look is written, as the effects warning already is. A styled group's style holds no fills or strokes, and its members are written again after the look, so a member's stroke still warns. Strokes that no art style holds (gradient or pattern strokes, for example) still warn. --- crates/eps/src/ai_editing.rs | 2 +- crates/eps/src/ai_editing/tests.rs | 64 +++++++++++++++++++++++++++++ crates/eps/src/ai_editing/writer.rs | 10 ++++- 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 3ca8a66b5f..5ab99d2378 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -162,7 +162,7 @@ //! symbols past 4,096 as their art (symbol art past [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects //! in all left out), instances of symbols the document doesn't have as empty groups, symbols' 9-slice //! guides, export types and dynamic type left out, live objects (placed documents included) as their art, -//! strokes a plain line can't draw +//! strokes a plain line can't draw and the object's art style doesn't hold //! as their filled outlines, several fills or strokes as a group of a path for each, freeform //! gradient swatches, gradient swatches of more than 256 stops, swatches of patterns written as //! tiles and the placement of pattern swatches that move, turn or scale their pattern, the opacity and blending modes of single fills and strokes, opacity masks on diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 1ab878fc62..e9cc0ba669 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -6629,6 +6629,70 @@ fn every_effect_setting_written_on_an_object_is_read_back() { assert!(matches!(&spot.appearance.fill().unwrap().paint, Paint::Solid { swatch: Some(s), .. } if s == "Ink"), "{spot:?}"); } +/// A drawn look's strokes warn only where its art style leaves them out: a path with a drop shadow and +/// a stroke with a width profile is written with a style that holds the profile, and doesn't warn; +/// a group with a drop shadow whose member has a stroke inside the path still warns. +#[test] +fn a_drawn_looks_strokes_warn_only_where_its_style_leaves_them_out() { + let profile = vectorcraft_doc::WidthProfile { points: vec![(0.0, 1.0, 0.5), (0.5, 0.25, 0.75), (1.0, 0.0, 1.0)] }; + let (n, warnings, p) = read_one(|doc| { + let mut stroke = StrokeLayer::new(black_fill(), 4.0); + stroke.profile = Some(profile.clone()); + let ap = Appearance { items: vec![AppearanceItem::Stroke(stroke)], effects: vec![drop_shadow()], contents_index: None }; + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap) + }); + assert!(p.contains("%AI9_BeginArtStyles"), "{p}"); + assert!(!warnings.iter().any(|w| w == writer::STROKES), "{warnings:?}"); + assert_eq!(n.appearance.stroke().and_then(|s| s.profile.clone()), Some(profile)); + let (_, warnings, p) = read_one(|doc| { + let mut n = + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), Appearance::basic(Paint::None, black_fill(), 2.0)); + n.appearance.stroke_mut().unwrap().align = vectorcraft_doc::StrokeAlign::Inside; + let mut g = group(doc, vec![n], false); + g.appearance.effects = vec![drop_shadow()]; + g + }); + assert!(p.contains("%AI9_BeginArtStyles"), "{p}"); + assert!(warnings.iter().any(|w| w == writer::STROKES), "{warnings:?}"); +} + +/// A path linked to a graphic style with a drop shadow and a stroke with a width profile is written +/// with that style, which holds the profile, and doesn't warn; it reads back linked, with its +/// profile. A linked path whose fill is half opaque in Screen mode isn't written with its style, which +/// the reader reads on no object, and warns that the fill's transparency is left out. +#[test] +fn a_linked_paths_drawn_look_warns_only_for_what_its_style_leaves_out() { + let profile = vectorcraft_doc::WidthProfile { points: vec![(0.0, 1.0, 0.5), (0.5, 0.25, 0.75), (1.0, 0.0, 1.0)] }; + let linked = |doc: &mut Document, fill: FillLayer| { + let mut stroke = StrokeLayer::new(black_fill(), 4.0); + stroke.profile = Some(profile.clone()); + let ap = Appearance { + items: vec![AppearanceItem::Fill(fill), AppearanceItem::Stroke(stroke)], + effects: vec![drop_shadow()], + contents_index: None, + }; + let mut g = vectorcraft_doc::GraphicStyle::new("Linked", ap.clone()); + g.id = 7; + doc.graphic_styles.push(g); + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap); + n.extras_mut().graphic_style = Some(7); + n + }; + let (n, warnings, p) = read_one(|doc| linked(doc, FillLayer::new(solid(Color::rgb(0.2, 0.2, 0.9))))); + assert!(p.contains("1 (Linked) XW"), "{warnings:?}"); + assert!(!warnings.iter().any(|w| w == writer::STROKES), "{warnings:?}"); + assert!(n.extras().graphic_style.is_some(), "{n:?}"); + assert_eq!(n.appearance.stroke().and_then(|s| s.profile.clone()), Some(profile.clone())); + let (_, warnings, p) = read_one(|doc| { + let mut fill = FillLayer::new(solid(Color::rgb(0.2, 0.2, 0.9))); + fill.opacity = 0.5; + fill.blend = BlendMode::Screen; + linked(doc, fill) + }); + assert!(!p.contains("1 (Linked) XW"), "{p}"); + assert!(warnings.iter().any(|w| w == writer::ITEM_TRANSPARENCY), "{warnings:?}"); +} + /// Each effect a graphic style can hold, in a style of its own, reads back with its settings, whatever /// the writer's conversions to the file's units. #[test] diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index dea4ad0c24..2f010070d0 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -51,7 +51,7 @@ pub(super) const CLIP_PAINT: &str = "the fills and strokes of clipping paths tha pub(super) const CLIP_PAINT_MASKS: &str = "the opacity masks of clipping paths whose fills and strokes are written as paths of their own stay on the clipping path, not on those paths, in the editing data for other apps"; pub(super) const LIVE: &str = "blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art in the editing data for other apps"; -const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, dots, dashes fitted to corners, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps"; +pub(super) const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, dots, dashes fitted to corners, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps"; pub(super) const ITEM_TRANSPARENCY: &str = "the opacity and blending modes of single fills and strokes are left out of the editing data for other apps"; pub(super) const LAYER_MASKS: &str = "opacity masks on layers are left out of the editing data for other apps"; @@ -1028,7 +1028,13 @@ impl<'a> Writer<'a> { AppearanceItem::Fill(f) => self.fill_object(path, rule, f, xy), AppearanceItem::Stroke(s) if self.direct_stroke(s) => self.path_object(path, rule, None, Some(s), xy), AppearanceItem::Stroke(s) => { - self.warn(STROKES); + // In a styled path's or compound path's drawn look the stroke is kept by the object's + // art style, which is written only when the reader reads the object's appearance back + // from it. A styled group's style holds no fills or strokes: its members are written + // again after the look, and warn there. + if !self.in_look { + self.warn(STROKES); + } self.wrote_fixed = true; if self.brush_art(path, s, xy) { return; From ff6dac4a5d659074a997a687679a7593abdd8dbd Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 08:33:29 -0800 Subject: [PATCH 48/92] Shaper: a click on an original shape's outline enters Construction mode With a Shaper Group or one of its faces selected, a Shaper click within 3 screen pixels of one of the group's original shapes' outlines enters Construction mode with that shape selected, also where the outline runs through a merged face. shaper.select takes a tolerance in points (default 3, at most 1000); hidden and locked shapes are skipped, and a Shaper Group above the selected one still takes the click. --- crates/engine/src/cmd/shaper.rs | 44 ++++++++++++++-- crates/engine/src/tests/shaper.rs | 87 +++++++++++++++++++++++++++++++ crates/tools/src/extra.rs | 5 +- docs/shaper.md | 2 +- docs/target-app-parity.md | 1 + 5 files changed, 132 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/shaper.rs b/crates/engine/src/cmd/shaper.rs index 44ec938bc2..228251fa13 100644 --- a/crates/engine/src/cmd/shaper.rs +++ b/crates/engine/src/cmd/shaper.rs @@ -2,7 +2,9 @@ //! art retained underneath. Recipes select topology regions; source edits re-evaluate them in //! the same undo transaction. Rendering and exporters only see ordinary groups and paths. use super::pathops::Beside; -use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, has_doc, id_param, object_param, point_param}; +use super::{ + CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, has_doc, id_param, number_or, object_param, point_param, +}; use serde_json::{Value, json}; use std::{ collections::{BTreeMap, HashMap}, @@ -34,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> { "Select Shaper Face", [], None, - "{point: [x,y], source?: bool} click selects a group, then a face; source enters construction isolation and selects an original (while an opacity mask or a pattern is edited, it selects the original and the editing layer stays isolated); while a group's originals are isolated, it selects one of that group's originals → {id}", + "{point: [x,y], source?: bool, tolerance?: pt (3)} click selects a group, then a face; with the group or one of its faces selected, a click within tolerance of an original shape's outline enters construction isolation and selects that original; source enters construction isolation and selects an original (while an opacity mask or a pattern is edited, it selects the original and the editing layer stays isolated); while a group's originals are isolated, it selects one of that group's originals → {id}", has_doc, select ), @@ -734,16 +736,50 @@ fn group_at(d: &Document, point: Point, construction: bool, within: Option<NodeI }); groups.last().copied() } +/// The topmost editable Shaper Group that is selected, or has a face selected, with a visible, +/// unlocked original shape whose outline lies within `tolerance` of `point`, and the topmost such +/// shape in it; `None` when a Shaper Group that [`group_at`] finds at `point` lies above it. +fn outline_at(d: &Document, selection: &vectorcraft_doc::Selection, point: Point, tolerance: f64) -> Option<(NodeId, NodeId)> { + let mut hit = None; + let editable = d.editable(); + d.walk(|n| { + if n.extras().shaper.is_none() || !editable(n.id) { + return; + } + let picked = selection.contains(n.id) || selection.objects.iter().any(|id| d.parent_of(*id) == Some(n.id)); + let on_outline = + |s: &&Arc<Node>| b::node_outline(s).is_some_and(|(path, _)| path.nearest(point).is_some_and(|(_, _, _, _, dist)| dist <= tolerance)); + let source = if picked { sources(n).iter().rev().filter(|s| s.visible && !s.locked).find(on_outline).map(|s| s.id) } else { None }; + if let Some(source) = source { + hit = Some((n.id, source)); + } else if n.geometric_bounds().is_some_and(|b| b.inflate(6.0, 6.0).contains(point)) { + hit = None; + } + }); + hit +} fn select(s: &mut Session, p: &Value) -> Result<Value> { let point = point_param(p, "point").filter(|p| finite(*p)).ok_or_else(|| bad("shaper.select", "missing point"))?; let st = s.doc()?; let source = bool_or(p, "source", false); + let tolerance = number_or(p, "tolerance", 3.0, "shaper.select")?; + if !tolerance.is_finite() || !(0.0..=1000.0).contains(&tolerance) { + return Err(bad("shaper.select", "invalid tolerance")); + } // In Construction mode a source pick stays in the group whose source shapes are isolated. let isolated = st.isolation.and_then(|i| st.doc.parent_of(i)).filter(|g| st.doc.node(*g).and_then(source_group).is_some_and(|s| st.isolation == Some(s.id))); - let Some(group) = group_at(&st.doc, point, source, isolated.filter(|_| source)) else { return Ok(json!({"id": null})) }; + // With a group or one of its faces selected, a click on one of its original shapes' outlines + // enters Construction mode with the topmost such shape selected, wherever the outline lies. + let outline = if source { None } else { outline_at(&st.doc, &st.selection, point, tolerance) }; + let Some(group) = outline.map(|(g, _)| g).or_else(|| group_at(&st.doc, point, source, isolated.filter(|_| source))) else { + return Ok(json!({"id": null})); + }; let node = st.doc.node(group).ok_or(EngineError::NoNode(group))?; - let target = if source { + let source = source || outline.is_some(); + let target = if let Some((_, id)) = outline { + id + } else if source { sources(node) .iter() .rev() diff --git a/crates/engine/src/tests/shaper.rs b/crates/engine/src/tests/shaper.rs index 891d03e7a7..38e61241f0 100644 --- a/crates/engine/src/tests/shaper.rs +++ b/crates/engine/src/tests/shaper.rs @@ -259,6 +259,93 @@ fn shaper_double_click_selects_an_original_for_construction_editing() { assert_eq!(s.doc().unwrap().selection.objects, vec![g]); } +/// With a Shaper Group or one of its faces selected, a Shaper click on an original shape's outline +/// enters Construction mode with that shape selected, even where the outline lies inside a merged +/// face. A click away from every outline still selects a face, and a click on an outline of a group +/// that is not selected selects the group. +#[test] +fn shaper_click_on_an_original_outline_enters_construction_mode() { + let mut s = session(); + let a = rectangle(&mut s, 0.0, "#ff0000"); + let b = rectangle(&mut s, 50.0, "#0000ff"); + let g = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]); + let sources = node(&s, g).children().unwrap()[0].id; + let v = ViewInfo { smart_guides: false, ..Default::default() }; + s.select_tool("shaper", v).unwrap(); + let click = |s: &mut Session, x: f64, y: f64| { + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, x, y), v).unwrap(); + } + }; + s.execute("select.none", &json!({})).unwrap(); + click(&mut s, 101.0, 50.0); + assert_eq!(s.doc().unwrap().selection.objects, vec![g]); + assert_eq!(s.doc().unwrap().isolation, None); + // The right side of the back shape runs through the merged face. + click(&mut s, 101.0, 50.0); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + assert_eq!(s.doc().unwrap().isolation, Some(sources)); + s.execute("object.exitIsolation", &json!({})).unwrap(); + // A click inside the face, away from the outlines, selects the face. + click(&mut s, 75.0, 50.0); + let face = s.doc().unwrap().selection.objects.clone(); + assert_eq!(face.len(), 1); + assert_ne!(face, vec![g]); + assert_eq!(s.doc().unwrap().isolation, None); + // With the face selected, a click on the front shape's outline picks that shape. + click(&mut s, 149.0, 50.0); + assert_eq!(s.doc().unwrap().selection.objects, vec![b]); + assert_eq!(s.doc().unwrap().isolation, Some(sources)); + s.execute("object.exitIsolation", &json!({})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + for tolerance in [json!("3"), json!(-1), json!(1e9)] { + assert!(s.execute("shaper.select", &json!({"point": [101, 50], "tolerance": tolerance})).is_err(), "{tolerance}"); + assert_eq!(s.doc().unwrap().selection.objects, vec![g]); + assert_eq!(s.doc().unwrap().isolation, None); + } + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + s.execute("shaper.select", &json!({"point": [104, 50], "tolerance": 5})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + s.execute("object.exitIsolation", &json!({})).unwrap(); + // A wide tolerance (a zoomed-out view) reaches an outline from well outside the group's bounds. + s.execute("shaper.select", &json!({"point": [165, 50], "tolerance": 20})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![b]); + assert_eq!(s.doc().unwrap().isolation, Some(sources)); + // A locked or hidden original shape is not picked by its outline. + for hide in ["object.lock", "object.hide"] { + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + s.execute(hide, &json!({})).unwrap(); + s.execute("object.exitIsolation", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + s.execute("shaper.select", &json!({"point": [149, 50]})).unwrap(); + assert_ne!(s.doc().unwrap().selection.objects, vec![b], "{hide}"); + assert_eq!(s.doc().unwrap().isolation, None, "{hide}"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + } +} + +/// A click on a selected Shaper Group's original outline where another Shaper Group lies above it +/// selects the group above, as a click there did before. +#[test] +fn shaper_outline_click_under_another_group_selects_the_group_above() { + let mut s = session(); + rectangle(&mut s, 0.0, "#ff0000"); + rectangle(&mut s, 50.0, "#0000ff"); + let lower = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]); + let c = rectangle(&mut s, 20.0, "#00ff00"); + let d = rectangle(&mut s, 70.0, "#ffff00"); + let upper = NodeId( + s.execute("shaper.scribble", &json!({"points": [[45, 20], [95, 35], [50, 50], [145, 65]], "ids": [c.0, d.0]})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("select.set", &json!({"ids": [lower.0]})).unwrap(); + s.execute("shaper.select", &json!({"point": [100, 50]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![upper]); + assert_eq!(s.doc().unwrap().isolation, None); +} + /// The arrow widget beside a selected Shaper Group switches it to Construction mode and back, with /// the Shaper tool and with the Selection tool. In Construction mode a Shaper tap selects a source /// shape, and the mode lasts through undo and redo of an edit made in it. diff --git a/crates/tools/src/extra.rs b/crates/tools/src/extra.rs index 4f8e867a54..d1a2672402 100644 --- a/crates/tools/src/extra.rs +++ b/crates/tools/src/extra.rs @@ -559,10 +559,11 @@ impl Tool for ShaperTool { } let pts = std::mem::take(&mut self.points); if pts.first().is_some_and(|first| pts.iter().all(|p| p.distance(*first) < cx.tol(3.0))) { - // In Construction mode a tap selects a source shape. + // In Construction mode a tap selects a source shape. On a selected group, a tap on a + // source shape's outline enters Construction mode. let construction = cx.isolation.and_then(|id| cx.doc.node(id)).is_some_and(|n| n.name.as_deref() == Some(vectorcraft_doc::shaper::SOURCES)); - let mut params = json!({"point": [ev.pos.x, ev.pos.y]}); + let mut params = json!({"point": [ev.pos.x, ev.pos.y], "tolerance": cx.tol(3.0).min(1000.0)}); if construction { params["source"] = json!(true); } diff --git a/docs/shaper.md b/docs/shaper.md index d32659478e..2318edd8a8 100644 --- a/docs/shaper.md +++ b/docs/shaper.md @@ -9,7 +9,7 @@ Scribble over overlapping paths to make an editable Shaper Group: - End the scribble outside the artwork to remove the touched fills and exposed boundary strokes. Shared edges along surviving filled areas remain, closing their contours. Compatible stroke pieces join at the new corners, so the selected stroke’s join style applies there. - Scribble over an unfilled stroke segment to remove that piece. A line passing through a shape is split at the intersections, so its protruding ends can be removed separately. -Click once to select the group; click again to select its visible fill and change its color. Double-click with Shaper to enter construction editing and select an original shape. The Selection tool then moves or resizes it normally, and the visible composition updates. Escape leaves construction editing. The original live shapes and edit recipes survive native save/reopen. Outside construction editing, selection highlights and bounds show only the visible result. Outside construction editing, a marquee selects the group or its faces only where it reaches the visible result, and Direct Selection's clicks and marquee select only the anchor points of the visible result. +Click once to select the group; click again to select its visible fill and change its color. Double-click with Shaper to enter construction editing and select an original shape. With the group or one of its faces selected, a Shaper click on an original shape's outline also enters construction editing and selects that shape, even where the outline runs through a merged face ([help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/create-shapes-with-the-shaper-tool.html)). The click counts within 3 screen pixels of the outline, a VectorCraft value. The Selection tool then moves or resizes it normally, and the visible composition updates. Escape leaves construction editing. The original live shapes and edit recipes survive native save/reopen. Outside construction editing, selection highlights and bounds show only the visible result. Outside construction editing, a marquee selects the group or its faces only where it reaches the visible result, and Direct Selection's clicks and marquee select only the anchor points of the visible result. While the group or one of its faces is selected (Face Selection mode) and the Shaper or Selection tool is active, an arrow widget sits beside the middle of the right side of the original shapes’ bounds. Click it to enter Construction mode: the original shapes are isolated and nothing is selected, and a click with Shaper selects the group’s original shape under the pointer. Click the widget again to leave Construction mode with the group selected. The arrow points right in Face Selection mode and left in Construction mode. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d42a4d988b..15d48f1dbc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -926,6 +926,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Tools:** - **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing, and with the Shaper or Selection tool a click on the arrow widget beside a Shaper Group's original shapes switches the group between Face Selection and Construction mode. Native save/reopen, face recoloring, Release and Expand are covered. Finer gesture heuristics remain. In Construction mode, a Selection-tool drag that drops an original shape wholly outside the bounds of the group's original shapes takes it out of the group in the move's undo step (Remove from Shaper Group, `shaper.releaseSource`): the shape becomes a path above the group and stays selected, and a group left with no shapes is removed. Engine tests drag a shape out of a two-shape group and back across the bounds, take the last shape out, undo, and save the result. + With a Shaper Group or one of its faces selected, a Shaper click within 3 screen pixels of an original shape's outline enters Construction mode with that shape selected, even where the outline runs through a merged face (`shaper.select`'s `tolerance`; [help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/create-shapes-with-the-shaper-tool.html)). Test: `tests::shaper::shaper_click_on_an_original_outline_enters_construction_mode`. - **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it). The search for the segment of a path nearest a point, which Direct Selection runs to pick the cursor over a segment, skips each segment whose anchors and handles lie in a box farther from the point than a segment already measured, starting with a segment at the anchor nearest the point. The segment found is the same as before. Over a segment of an open path of 200,000 anchors, the cursor takes 51 ms instead of 59 ms in a release build (`cargo run --release -p vectorcraft-engine --example anchor_bench`). A Direct Selection marquee over part of a segment, with neither of its anchors inside, selects that segment as a click on it does (Shift toggles it), as [Selecting objects](http://web.archive.org/web/20211230015528/https://helpx.adobe.com/illustrator/using/selecting-objects.html) describes (#4654). From 766e291e3cf8495fb7708e0645077fe4ce57eba1 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 07:09:32 -0800 Subject: [PATCH 49/92] Save and Export: notes of what the file loses come before notes on options Each writer marks its notes as a loss (something of the document that the file leaves out or that reaches it changed) or as a note on the options (an option it didn't apply or applied another way while the art reaches the file as it looks, or something an option turns off). The PDF, SVG, EPS, legacy .ai, DXF and HP-GL writers return their notes on the options in a list of their own (`setting_notes`) beside `warnings`, and the engine's `Encoded` does the same. The replies of the save and export commands, and of `clipboard.exportPdf` and `clipboard.exportPng`, list the losses first, each kind in the order the writers gave them, across every file that Export for Screens writes. The status bar names the first note, so an export to lossy WebP of type in a font that isn't installed now names the fallback-font note instead of the lossy WebP note. A print to a file keeps the order its notes are given in. --- crates/cad/src/hpgl/write.rs | 18 +-- crates/cad/src/hpgl/write/tests.rs | 13 +- crates/cad/src/lib.rs | 3 + crates/cad/src/scene.rs | 18 ++- crates/cad/src/tests.rs | 21 ++-- crates/engine/src/cmd/css.rs | 16 +-- crates/engine/src/cmd/fileio.rs | 2 +- crates/engine/src/cmd/fileio/dxf.rs | 1 + crates/engine/src/cmd/fileio/encode.rs | 111 ++++++++++++++++-- crates/engine/src/cmd/fileio/eps.rs | 1 + crates/engine/src/cmd/fileio/export.rs | 4 +- crates/engine/src/cmd/fileio/hpgl.rs | 1 + crates/engine/src/cmd/fileio/legacy_ai.rs | 5 +- crates/engine/src/cmd/fileio/pdf.rs | 49 ++++---- crates/engine/src/cmd/fileio/save.rs | 21 ++-- crates/engine/src/cmd/fileio/screens.rs | 23 ++-- crates/engine/src/cmd/fileio/svg.rs | 1 + crates/engine/src/cmd/fileio/tests_dxf.rs | 15 +++ .../engine/src/cmd/fileio/tests_exportas.rs | 32 +++++ .../src/cmd/fileio/tests_pdfsecurity.rs | 18 +++ crates/engine/src/cmd/fileio/tests_screens.rs | 16 +++ crates/engine/src/cmd/rasterfx.rs | 4 +- crates/eps/src/legacy_ai.rs | 4 +- crates/eps/src/legacy_ai/tests.rs | 4 +- crates/eps/src/legacy_ai/writer.rs | 16 ++- crates/eps/src/lib.rs | 10 +- crates/eps/src/print.rs | 2 +- crates/eps/src/tests.rs | 2 +- crates/pdf/src/export/entry.rs | 22 ++-- crates/pdf/src/export/writer.rs | 14 ++- crates/pdf/src/lib.rs | 3 + crates/pdf/src/output.rs | 2 +- crates/pdf/src/post.rs | 8 +- crates/pdf/src/print.rs | 4 +- crates/pdf/src/tests/editing.rs | 3 +- crates/pdf/src/tests/layers.rs | 6 +- crates/pdf/src/tests/marks.rs | 11 +- crates/pdf/src/tests/output.rs | 2 +- crates/pdf/src/tests/pdf13.rs | 2 +- crates/pdf/src/tests/print.rs | 2 +- crates/pdf/src/tests/realtext.rs | 2 +- crates/pdf/src/tests/settings.rs | 7 +- crates/pdf/src/tests/webview.rs | 2 +- crates/svg/src/export.rs | 7 +- crates/svg/src/export/output.rs | 10 ++ crates/svg/src/export/text.rs | 2 +- crates/svg/src/lib.rs | 8 +- crates/svg/tests/it/data_attributes.rs | 2 +- crates/svg/tests/it/encoding_profile_fonts.rs | 2 +- crates/ui-egui/src/io.rs | 17 +++ crates/ui-egui/src/io/export.rs | 2 +- docs/mcp.md | 11 +- docs/target-app-parity.md | 1 + 53 files changed, 433 insertions(+), 150 deletions(-) diff --git a/crates/cad/src/hpgl/write.rs b/crates/cad/src/hpgl/write.rs index 3e20c69a98..6ab80c8ba0 100644 --- a/crates/cad/src/hpgl/write.rs +++ b/crates/cad/src/hpgl/write.rs @@ -187,6 +187,9 @@ pub struct HpglOutput { pub pens: Vec<[u8; 3]>, /// What was approximated or left out. pub warnings: Vec<String>, + /// Notes on the options: sorting the paths or drawing the inner paths first was applied only + /// in part. + pub setting_notes: Vec<String>, } /// Write the visible art of `doc` over `opts.region` as an HP-GL plot. Live geometry effects are @@ -280,10 +283,11 @@ pub fn export_regions(doc: &Document, opts: &HpglOptions, regions: &[Rect]) -> R if !by_pen.iter().all(Vec::is_empty) { pen_warnings.iter().for_each(|w| warn(&mut warnings, w)); } + let mut setting_notes = vec![]; if opts.sort || opts.inside_first { let (mut at, mut work) = ((0, 0), MAX_SORT_WORK); for paths in &mut by_pen { - at = reorder(paths, at, &mut work, opts.sort, opts.inside_first, &mut warnings); + at = reorder(paths, at, &mut work, opts.sort, opts.inside_first, &mut setting_notes); } } if opts.version == Version::Hpgl && by_pen.iter().flatten().flatten().any(|(x, y)| !CLASSIC_RANGE.contains(x) || !CLASSIC_RANGE.contains(y)) { @@ -293,7 +297,7 @@ pub fn export_regions(doc: &Document, opts: &HpglOptions, regions: &[Rect]) -> R Version::Hpgl => plain(&by_pen), Version::Hpgl2 => encoded(&by_pen, &pens), }; - out.push(HpglOutput { bytes, pens: pens.clone(), warnings }); + out.push(HpglOutput { bytes, pens: pens.clone(), warnings, setting_notes }); } Ok(out) } @@ -366,20 +370,20 @@ fn box_dist2(p: (i64, i64), b: [i64; 4]) -> i128 { /// for a closed path any of its points (where it then starts); without, the first in paint order. /// With `inside_first`, a path is drawn only once every path lying inside it ([`nest::outer`]) is. /// Once `work` is spent, the paths left keep paint order, each after the paths inside it with -/// `inside_first`, with a warning. Returns where the pen stops. +/// `inside_first`, with a note in `notes`. Returns where the pen stops. fn reorder( paths: &mut Vec<Vec<(i64, i64)>>, mut at: (i64, i64), work: &mut u64, nearest: bool, inside_first: bool, - warnings: &mut Vec<String>, + notes: &mut Vec<String>, ) -> (i64, i64) { let boxes: Vec<[i64; 4]> = paths.iter().map(|p| nest::bounds(p)).collect(); // The paths each path lies inside, and how many paths inside each are still to draw. let outer = inside_first.then(|| nest::outer(paths, &boxes, work)).flatten(); if inside_first && outer.is_none() { - warn(warnings, UNNESTED); + warn(notes, UNNESTED); } let mut waiting = vec![0usize; paths.len()]; for o in outer.iter().flatten().flatten() { @@ -430,11 +434,11 @@ fn reorder( if *work == 0 || best.0 >= left.len() { match &outer { Some(outer) => { - warn(warnings, UNSORTED_INSIDE); + warn(notes, UNSORTED_INSIDE); inner_first(left.into_iter().map(|(j, p, _)| (j, p)).collect(), outer, &mut waiting, paths); } None => { - warn(warnings, UNSORTED); + warn(notes, UNSORTED); paths.extend(left.into_iter().map(|(_, p, _)| p)); } } diff --git a/crates/cad/src/hpgl/write/tests.rs b/crates/cad/src/hpgl/write/tests.rs index c29ef67532..9cb4f7d727 100644 --- a/crates/cad/src/hpgl/write/tests.rs +++ b/crates/cad/src/hpgl/write/tests.rs @@ -852,7 +852,8 @@ fn sorting_measures_a_closed_path_by_its_nearest_point() { fn sorting_stops_at_its_budget_and_keeps_every_path() { let d = doc_with(|d| (0..600).map(|i| stroked(d, line((1.0 + (i % 100) as f64, 10.0), (1.0 + (i % 100) as f64, 20.0)), Color::BLACK)).collect()); let out = plot(&d, &HpglOptions { sort: true, ..opts(&d) }); - assert!(out.warnings.iter().any(|w| w == UNSORTED), "{:?}", out.warnings); + assert_eq!(out.setting_notes, [UNSORTED]); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); assert_eq!(text(&out).matches("PD").count(), 600); } @@ -944,18 +945,18 @@ fn inside_first_orders_each_pens_paths() { } /// Finding which of 100 nested rings lie inside which is past the budget: they are drawn in -/// paint order, with a warning. +/// paint order, with a note on the option. #[test] fn inside_first_past_its_budget_draws_in_paint_order() { let d = doc_with(|d| (0..100).map(|i| stroked(d, rect(1.0 + 0.4 * f64::from(i), 10.0, 199.0, 90.0), Color::BLACK)).collect()); let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) }); - assert_eq!(out.warnings, [UNNESTED]); + assert_eq!(out.setting_notes, [UNNESTED]); let l = lefts(&out); assert_eq!(l.len(), 100); assert!(l.windows(2).all(|w| w[0] <= w[1]), "{l:?}"); - // With `sort` too, the budget is spent before sorting starts: paint order, with both warnings. + // With `sort` too, the budget is spent before sorting starts: paint order, with both notes. let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) }); - assert_eq!(out.warnings, [UNNESTED, UNSORTED]); + assert_eq!(out.setting_notes, [UNNESTED, UNSORTED]); assert_eq!(lefts(&out), l); } @@ -969,7 +970,7 @@ fn inside_first_past_its_sorting_budget_keeps_the_rings_last() { v }); let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) }); - assert_eq!(out.warnings, [UNSORTED_INSIDE]); + assert_eq!(out.setting_notes, [UNSORTED_INSIDE]); let l = lefts(&out); assert_eq!(l.len(), 602); assert_eq!(l.get(600..), Some(&[plu(0.7), plu(0.5)][..])); diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 0ef28a4aaa..1443829806 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -410,6 +410,9 @@ pub struct DxfOutput { pub images: Vec<DxfImage>, /// What was approximated or left out. pub warnings: Vec<String>, + /// What was left out because an option turns it off (Include Placed Images or Include Fill + /// Hatches off). + pub setting_notes: Vec<String>, } /// The smallest and largest `scale` (drawing units per unit). diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 7c5d8b6755..0379fc4ea5 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -520,6 +520,8 @@ pub(crate) struct Scene<'a> { /// Image keys → index into `images` (`None`: left out). by_key: HashMap<String, Option<usize>>, warnings: Vec<String>, + /// What was left out because an option asked for it ([`DxfOutput::setting_notes`]). + setting_notes: Vec<String>, brushes: Option<Vec<vectorcraft_brush::Brush>>, extents: Option<[f64; 4]>, /// The opacity of the containers around the current object. @@ -554,6 +556,7 @@ impl<'a> Scene<'a> { files: vec![], by_key: HashMap::new(), warnings: vec![], + setting_notes: vec![], brushes: None, extents: None, opacity: 1.0, @@ -588,15 +591,24 @@ impl<'a> Scene<'a> { metric: matches!(self.o.unit, Unit::Millimeters | Unit::Centimeters | Unit::Meters), next: self.next, }; - DxfOutput { bytes: drawing.write().into_bytes(), images: self.files, warnings: self.warnings } + DxfOutput { bytes: drawing.write().into_bytes(), images: self.files, warnings: self.warnings, setting_notes: self.setting_notes } } + /// Note `w`, once, about something of the document that the drawing leaves out or that reaches + /// it changed. A note about an option goes to [`Self::note_setting`]. fn warn(&mut self, w: &str) { if !self.warnings.iter().any(|x| x == w) { self.warnings.push(w.to_string()); } } + /// Note `w`, once, about something left out because an option turns it off. + fn note_setting(&mut self, w: &str) { + if !self.setting_notes.iter().any(|x| x == w) { + self.setting_notes.push(w.to_string()); + } + } + fn handle(&mut self) -> Handle { let h = Handle(self.next); self.next = self.next.saturating_add(1); @@ -930,7 +942,7 @@ impl<'a> Scene<'a> { // The outlines close as the fill does, so an open path's fill keeps its closing edge. self.lines(&closed_fill(outline), style, 0.0); if !self.o.include_hatches { - self.warn(HATCHES_OFF); + self.note_setting(HATCHES_OFF); return; } let h = self.handle(); @@ -1321,7 +1333,7 @@ impl<'a> Scene<'a> { return; } if !self.o.include_images { - self.warn(IMAGES_OFF); + self.note_setting(IMAGES_OFF); return; } let Some(i) = self.image_def(im) else { return }; diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 9d2a519e1d..96c037a4a7 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -881,12 +881,13 @@ fn fills_without_hatches_keep_their_outlines() { let d = shapes_doc(); let out = export(&d, &DxfOptions { include_hatches: false, ..opts(&d) }).unwrap(); assert_eq!(kinds(&out), ["LWPOLYLINE", "LWPOLYLINE", "CIRCLE"], "the fill's outline, the stroke, the circle"); - assert_eq!(out.warnings, [HATCHES_OFF]); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(out.setting_notes, [HATCHES_OFF]); let e = entities(&out); assert_eq!((get(&e[0], 70), get(&e[0], 370)), (Some("1"), None), "the closed outline, without a lineweight"); // Hatches stay by default, with no note. let on = export(&d, &opts(&d)).unwrap(); - assert!(kinds(&on).iter().any(|k| k == "HATCH") && !on.warnings.iter().any(|w| w == HATCHES_OFF)); + assert!(kinds(&on).iter().any(|k| k == "HATCH") && on.setting_notes.is_empty()); // The outlines are the ones written beside hatches, also for overlapping subpaths, which are // merged first; a fill with no area (a flat rectangle, a diagonal line) writes nothing either way. let compound = doc_with(|d| { @@ -916,13 +917,14 @@ fn fills_without_hatches_keep_their_outlines() { assert!(!kinds(&altered).iter().any(|k| k == "HATCH") && !kinds(&altered).is_empty(), "{:?}", kinds(&altered)); // Nothing filled: no note. let lines = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(120.0, 20.0, 180.0, 80.0)), Paint::None, Paint::solid(Color::BLACK), 1.0)]); - assert!(export(&lines, &DxfOptions { include_hatches: false, ..opts(&lines) }).unwrap().warnings.is_empty()); + let none = export(&lines, &DxfOptions { include_hatches: false, ..opts(&lines) }).unwrap(); + assert!(none.warnings.is_empty() && none.setting_notes.is_empty()); // R12 has no hatches anyway: the same outlines, and its own note alone. let r12 = |include_hatches| export(&d, &DxfOptions { version: DxfVersion::R12, include_hatches, ..opts(&d) }).unwrap(); let (off, on) = (r12(false), r12(true)); assert_eq!(off.bytes, on.bytes); assert_eq!(off.warnings, on.warnings); - assert!(!off.warnings.iter().any(|w| w == HATCHES_OFF)); + assert!(off.setting_notes.is_empty()); } #[test] @@ -957,15 +959,18 @@ fn images_can_be_left_out_with_their_files() { let p = pairs(&out.bytes); assert!(!section(&p, "OBJECTS").iter().any(|o| matches!(kind(o), "IMAGEDEF" | "IMAGEDEF_REACTOR" | "RASTERVARIABLES"))); assert!(!section(&p, "CLASSES").iter().any(|o| get(o, 1) == Some("IMAGE"))); - assert_eq!(out.warnings, [IMAGES_OFF]); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(out.setting_notes, [IMAGES_OFF]); // An image that can't be decoded is not read when images are left out. let mut broken = d.clone(); broken.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(b"not a png".to_vec()), proxy: None }); - assert_eq!(export(&broken, &DxfOptions { include_images: false, ..opts(&broken) }).unwrap().warnings, [IMAGES_OFF]); + let left_out = export(&broken, &DxfOptions { include_images: false, ..opts(&broken) }).unwrap(); + assert!(left_out.warnings.is_empty(), "{:?}", left_out.warnings); + assert_eq!(left_out.setting_notes, [IMAGES_OFF]); assert!(export(&broken, &opts(&broken)).unwrap().warnings.iter().any(|w| w.contains("couldn't be decoded"))); // R12 has no images anyway: its own note alone. let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, include_images: false, ..opts(&d) }).unwrap(); - assert!(r12.warnings.iter().any(|w| w.contains("no images")) && !r12.warnings.iter().any(|w| w == IMAGES_OFF)); + assert!(r12.warnings.iter().any(|w| w.contains("no images")) && r12.setting_notes.is_empty()); } #[test] @@ -983,7 +988,7 @@ fn images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_b let cnc = run(DxfPreset::Cnc.options()); assert_eq!(kinds(&cnc), ["LWPOLYLINE"]); assert!(cnc.images.is_empty()); - assert!(cnc.warnings.iter().any(|w| w == IMAGES_OFF) && cnc.warnings.iter().any(|w| w == HATCHES_OFF), "{:?}", cnc.warnings); + assert!(cnc.setting_notes.iter().any(|w| w == IMAGES_OFF) && cnc.setting_notes.iter().any(|w| w == HATCHES_OFF), "{:?}", cnc.setting_notes); let custom = DxfPreset::Custom.options(); assert!(custom.include_images && custom.include_hatches, "Custom keeps both"); } diff --git a/crates/engine/src/cmd/css.rs b/crates/engine/src/cmd/css.rs index ed83a01410..9e18df76bf 100644 --- a/crates/engine/src/cmd/css.rs +++ b/crates/engine/src/cmd/css.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::NodeId; use vectorcraft_svg::{CssOptions, CssSheet, css_rules}; -use super::fileio::{default_name, encode_with_warnings, isolated, write_file}; +use super::fileio::{Note, default_name, encode_with_notes, isolated, note_texts, write_file}; use super::{CommandSpec, Result, Session, bad, cmd, has_doc, str_param, targets}; pub fn specs() -> Vec<CommandSpec> { @@ -100,20 +100,22 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> { } let css = format!("{}\n", sheet.text()); let doc = &s.doc()?.doc; - // Encode the pictures of rasterized art, and add each of their warnings once. + // Encode the pictures of rasterized art, and add each of their warnings once. What CSS can't + // describe is a loss. let mut images = Vec::new(); - let mut warnings = sheet.warnings.clone(); + let mut notes: Vec<Note> = sheet.warnings.iter().cloned().map(Note::loss).collect(); for r in &sheet.rules { let Some(name) = &r.image else { continue }; let (d, _) = isolated(doc, &[r.id], name).ok_or_else(|| bad(C, format!("{} paints nothing to rasterize", r.selector)))?; - let (png, notes) = encode_with_warnings(&d, "png", &json!({}))?; + let (png, given) = encode_with_notes(&d, "png", &json!({}))?; images.push((name, png)); - for w in notes { - if !warnings.contains(&w) { - warnings.push(w); + for n in given { + if !notes.iter().any(|x| x.text == n.text) { + notes.push(n); } } } + let warnings = note_texts(¬es); let n = sheet.rules.len(); match str_param(p, "path") { Some(path) => { diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 329988cb34..b2644c1f29 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -66,7 +66,7 @@ use serde_json::{Value, json}; pub use action_batch::batch_overwrites_one_file; pub use dxf::{UNSUPPORTED, Unsupported, unsupported}; -pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings}; +pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, Note, NoteKind, encode, encode_all, encode_with_notes, encode_with_warnings, note_texts}; pub(crate) use encode::{anti_alias, background, with_single_artboard}; pub(crate) use export::with_artboard_fills; pub(crate) use export::{export_crop, isolated}; diff --git a/crates/engine/src/cmd/fileio/dxf.rs b/crates/engine/src/cmd/fileio/dxf.rs index 16cf3b2631..0fcaa4bfc0 100644 --- a/crates/engine/src/cmd/fileio/dxf.rs +++ b/crates/engine/src/cmd/fileio/dxf.rs @@ -241,6 +241,7 @@ pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option<bool>) -> enc.warnings.push(w); } } + enc.add_setting_notes(out.setting_notes); for img in out.images { if !enc.linked.iter().any(|l| l.name == img.name) { enc.linked.push(vectorcraft_svg::LinkedImage { name: img.name, bytes: Arc::new(img.bytes) }); diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 0f91007515..1be80f2c56 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -74,18 +74,104 @@ pub struct Encoded { pub linked: Vec<vectorcraft_svg::LinkedImage>, /// Image maps, each written beside its image under the image's name (raster `imageMap`). pub maps: Vec<imagemap::Map>, - /// The encoder's warnings (features approximated or left out, options not applied yet). + /// The encoder's warnings: what the file leaves out of the document or writes differently + /// ([`NoteKind::Loss`]). pub warnings: Vec<String>, + /// The encoder's notes on the options: options not applied yet or applied another way, and + /// what an option turns off ([`NoteKind::Setting`]). A reply gives them after the warnings + /// ([`Self::notes`]). + pub setting_notes: Vec<String>, /// What joins the file's stem and the artboard name when there is a file per artboard /// (default `-`; EPS uses `_`, as the reference app names them). pub joiner: Option<&'static str>, } +/// What a note an export gives with its file is about. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NoteKind { + /// Something of the document (its art, type, layers or artboards) that the file leaves out or + /// that reaches it changed: the format, its version or a limit can't hold it, or the export + /// couldn't write it. + Loss, + /// An option the export didn't apply or applied another way while the art reaches the file as + /// it looks, or something left out because an option turns it off. + Setting, +} + +/// A note an export gives with its file. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Note { + /// What the note is about. + pub kind: NoteKind, + /// The note, in English. + pub text: String, +} + +impl Note { + /// A note about what the file loses ([`NoteKind::Loss`]). + pub fn loss(text: impl Into<String>) -> Self { + Self { kind: NoteKind::Loss, text: text.into() } + } + + /// A note on the options ([`NoteKind::Setting`]). + pub fn setting(text: impl Into<String>) -> Self { + Self { kind: NoteKind::Setting, text: text.into() } + } +} + +/// The texts of `notes` in the order a reply gives them: the losses first, then the notes on the +/// options, each kind in the order given (the status bar names the first). +pub fn note_texts(notes: &[Note]) -> Vec<String> { + let of = |kind| notes.iter().filter(move |n| n.kind == kind).map(|n| n.text.clone()); + of(NoteKind::Loss).chain(of(NoteKind::Setting)).collect() +} + +/// Add `note` to `notes` unless a note with its text is there already. +pub(super) fn add_note(notes: &mut Vec<Note>, note: Note) { + if !notes.iter().any(|n| n.text == note.text) { + notes.push(note); + } +} + impl Encoded { pub(super) fn one(bytes: Vec<u8>) -> Self { Self { files: vec![(None, bytes)], ..Self::default() } } + /// The file `bytes` with `notes`, each in the list of its kind, in the order given. + pub(super) fn one_with_notes(bytes: Vec<u8>, notes: Vec<Note>) -> Self { + let mut enc = Self::one(bytes); + for n in notes { + match n.kind { + NoteKind::Loss => enc.warnings.push(n.text), + NoteKind::Setting => enc.setting_notes.push(n.text), + } + } + enc + } + + /// Add each of `texts` to [`Self::setting_notes`] unless a note with its text is there already. + pub(super) fn add_setting_notes(&mut self, texts: impl IntoIterator<Item = String>) { + for t in texts { + if !self.warnings.contains(&t) && !self.setting_notes.contains(&t) { + self.setting_notes.push(t); + } + } + } + + /// Every note with its kind: the warnings, then the notes on the options that aren't also + /// warnings. + pub fn kinded_notes(&self) -> Vec<Note> { + let settings = self.setting_notes.iter().filter(|t| !self.warnings.contains(t)).map(Note::setting); + self.warnings.iter().map(Note::loss).chain(settings).collect() + } + + /// The notes' texts in the order a reply gives them ([`note_texts`]): the warnings, then the + /// notes on the options, each once. + pub fn notes(&self) -> Vec<String> { + note_texts(&self.kinded_notes()) + } + /// Every file to write for the destination `path` (a path or a file name): the file itself /// when there is one, else `{stem}-{artboard name}.{ext}` beside it (see `joiner`; a file of no artboard /// among several, as Save's master file, is the file itself), then the linked images beside it @@ -128,13 +214,14 @@ impl Encoded { out } - /// The one file and the warnings, when the export wrote nothing else. - fn single(self, f: &Format) -> Result<(Vec<u8>, Vec<String>)> { + /// The one file and the notes, when the export wrote nothing else. + fn single(self, f: &Format) -> Result<(Vec<u8>, Vec<Note>)> { if !self.linked.is_empty() || !self.maps.is_empty() { return Err(bad(C, format!("{} with linked images or an image map writes several files: use document.export", f.label))); } + let notes = self.kinded_notes(); match <[_; 1]>::try_from(self.files) { - Ok([(_, bytes)]) => Ok((bytes, self.warnings)), + Ok([(_, bytes)]) => Ok((bytes, notes)), Err(_) => Err(bad(C, format!("{} writes one file per artboard here: use document.export, or name one artboard", f.label))), } } @@ -419,8 +506,14 @@ pub fn encode(doc: &Document, format: &str, p: &Value) -> Result<Vec<u8>> { /// Like [`encode`], also returning the encoder's warnings (PDF and SVG: options not applied yet, /// features approximated or left out; every format that draws type: fonts written in the fallback -/// font; PDF, EPS, SVG, EMF, WMF and the raster formats: type that doesn't fit its frame). +/// font; PDF, EPS, SVG, EMF, WMF and the raster formats: type that doesn't fit its frame), what +/// the file loses first ([`note_texts`]). pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<(Vec<u8>, Vec<String>)> { + encode_with_notes(doc, format, p).map(|(bytes, notes)| (bytes, note_texts(¬es))) +} + +/// Like [`encode_with_warnings`], with each note's kind ([`Encoded::kinded_notes`]). +pub fn encode_with_notes(doc: &Document, format: &str, p: &Value) -> Result<(Vec<u8>, Vec<Note>)> { let f = super::writable(C, Some(format), None)?; let enc = encode_all(doc, f.id, p)?; // The error for a version 7 .ai of several artboards names the legacy .ai. @@ -476,7 +569,7 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> { /// ([`vectorcraft_plugins::export`]). The plug-in is given the native file's JSON /// ([`vectorcraft_format::json_part`]) and `p`'s `params`, for the file `p`'s `path` names (else /// one named after the document) with that path's extension when the plug-in lists it, else its -/// first. Its warnings come back with the file. +/// first. Its warnings come back with the file, as losses (the plug-in gives them no kind). fn plugin_file(doc: &Document, plugin: &vectorcraft_plugins::Plugin, f: &Format, p: &Value) -> Result<Encoded> { use vectorcraft_plugins::{Error, export}; let doc = &*doc.without_edit_modes(); @@ -539,8 +632,8 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> { "hpgl" => return super::hpgl::encode(doc, p, use_artboards), "ai" => return super::save::encode_ai(C, f, doc, p), "pdf" => { - let (bytes, warnings) = super::pdf::encode(C, doc, p)?; - return Ok(Encoded { warnings, ..Encoded::one(bytes) }); + let (bytes, notes) = super::pdf::encode(C, doc, p)?; + return Ok(Encoded::one_with_notes(bytes, notes)); } "png" | "jpg" | "webp" | "gif" | "png8" | "tiff" | "bmp" | "tga" | "psd" => { let o: RasterOptions = options(f, p)?; @@ -607,7 +700,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> { let regions: Vec<Rect> = enc.files.iter().filter_map(|(b, _)| b.and_then(|b| doc.artboards.get(b))).map(|a| a.rect).collect(); enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions))); if format == RasterFormat::WebP && o.lossless == Some(false) { - enc.warnings.push("lossy WebP isn't available yet: the file is lossless (exact, but larger)".into()); + enc.setting_notes.push("lossy WebP isn't available yet: the file is lossless (exact, but larger)".into()); } return Ok(enc); } diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index fe962a36ad..3acd23e854 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -382,6 +382,7 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result<Encoded> { enc.warnings.push(w); } } + enc.add_setting_notes(out.setting_notes); } enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions))); Ok(enc) diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 62e5b78e6b..19e496306f 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -42,7 +42,7 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> { } _ => json!({ "dataBase64": vectorcraft_format::base64_encode(bytes) }), }; - let mut out = merge(data(main.first().map_or(&[][..], |m| &m.1)), json!({ "warnings": enc.warnings })); + let mut out = merge(data(main.first().map_or(&[][..], |m| &m.1)), json!({ "warnings": enc.notes() })); if main.len() > 1 { out["files"] = main.iter().map(|(name, bytes)| merge(json!({ "name": name }), data(bytes))).collect(); } @@ -69,7 +69,7 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> { o.insert("_displayDocTitle".into(), json!(display_doc_title)); } let enc = encode_all(doc, f.id, &q)?; - write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.warnings })) + write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.notes() })) } /// The document an export of `st` in format `f` writes ([`export_source`]): a `.ai` or `.ait` diff --git a/crates/engine/src/cmd/fileio/hpgl.rs b/crates/engine/src/cmd/fileio/hpgl.rs index ff6e63fbd7..1baac844e6 100644 --- a/crates/engine/src/cmd/fileio/hpgl.rs +++ b/crates/engine/src/cmd/fileio/hpgl.rs @@ -116,6 +116,7 @@ pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option<bool>) -> enc.warnings.push(w); } } + enc.add_setting_notes(out.setting_notes); } if enc.files.is_empty() { return Err(bad(C, "the document has no artboard")); diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index 1fd3e10a9f..6ee826d5ea 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -136,10 +136,12 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result } let images = see_through_images(&d, mode); if !images.is_empty() { - note(&mut enc.warnings, if mode == SeeThrough::Flatten { FLATTENED } else { OMITTED }); if mode == SeeThrough::Flatten { + note(&mut enc.warnings, FLATTENED); flatten_images(&mut d, &images)?; } else { + // A note on the options: `transparentImages: omit` leaves them out. + enc.add_setting_notes([OMITTED.to_string()]); for (_, id) in images { let _ = d.remove(id); // Inside an image left out before: gone already. } @@ -150,6 +152,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result for w in placed.iter().chain(&out.warnings) { note(&mut enc.warnings, w); } + enc.add_setting_notes(out.setting_notes); enc.files.push((Some(b), out.bytes)); } // Expanding raster effects and flattening images draw the art, and an image the renderer diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 1f166797f8..b11251ba34 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -12,7 +12,7 @@ use vectorcraft_pdf::{Overprint, PdfError, PdfOptions, PdfPreset, PdfSettings, S use super::super::flatten::{FlattenOptions, flatten_document, see_through_images}; use super::super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param}; -use super::{ArtboardPick, FormatOption, write_or_return}; +use super::{ArtboardPick, FormatOption, Note, note_texts, write_or_return}; use crate::EngineError; const C: &str = "document.exportPdf"; @@ -284,21 +284,22 @@ pub const EDITING_NEEDS_EVERY_ARTBOARD: &str = /// ([`vectorcraft_pdf::PdfSettings::leaves_out_bleed`]). pub const BLEED_LEFT_OUT: &str = "the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings"; -/// Encode `doc` as PDF with the options in `p` → (bytes, warnings). Raster effects are rendered +/// Encode `doc` as PDF with the options in `p` → (bytes, notes). Raster effects are rendered /// to images at the document's raster effects resolution. Preserve Editing embeds `doc` itself /// when the PDF has every artboard (a PDF of some reopens as just those). -pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec<u8>, Vec<String>)> { +pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec<u8>, Vec<Note>)> { encode_at(cmd, doc, p, None) } /// [`encode`] modified at `modified` (Unix seconds) when given, instead of the time of writing. -pub(super) fn encode_at(cmd: &str, doc: &Document, p: &Value, modified: Option<i64>) -> Result<(Vec<u8>, Vec<String>)> { +pub(super) fn encode_at(cmd: &str, doc: &Document, p: &Value, modified: Option<i64>) -> Result<(Vec<u8>, Vec<Note>)> { // Keep the editable object tree independent of preview fallbacks used by the drawn pages. - let (full, mut warnings) = crate::cmd::place::document::full_documents(doc); - let (bytes, drawn_warnings) = encode_carrying_at(cmd, &full, p, modified, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?; - warnings.extend(drawn_warnings); - warnings.extend(overflow_warning(cmd, &full, p)?); - Ok((bytes, warnings)) + let (full, placed) = crate::cmd::place::document::full_documents(doc); + let mut notes: Vec<Note> = placed.into_iter().map(Note::loss).collect(); + let (bytes, drawn) = encode_carrying_at(cmd, &full, p, modified, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?; + notes.extend(drawn); + notes.extend(overflow_warning(cmd, &full, p)?.map(Note::loss)); + Ok((bytes, notes)) } /// The warning about the type that the pages of a PDF of `doc` with the options in `p` draw and @@ -321,7 +322,7 @@ pub(super) fn encode_carrying( p: &Value, native: impl FnOnce() -> Result<Vec<u8>>, private: impl FnOnce() -> Result<Option<(vectorcraft_pdf::PrivateData, Vec<String>)>>, -) -> Result<(Vec<u8>, Vec<String>)> { +) -> Result<(Vec<u8>, Vec<Note>)> { encode_carrying_at(cmd, doc, p, None, native, private) } @@ -334,7 +335,7 @@ fn encode_carrying_at( modified: Option<i64>, native: impl FnOnce() -> Result<Vec<u8>>, private: impl FnOnce() -> Result<Option<(vectorcraft_pdf::PrivateData, Vec<String>)>>, -) -> Result<(Vec<u8>, Vec<String>)> { +) -> Result<(Vec<u8>, Vec<Note>)> { let mut opts = options_at(cmd, doc, p, modified.or_else(vectorcraft_doc::metadata::output_time))?; // The pages' background colors, behind the art (the editing data keeps them as colors). let pages = opts.artboards.clone().unwrap_or_else(|| (0..doc.artboards.len()).collect()); @@ -345,21 +346,22 @@ fn encode_carrying_at( if opts.settings.thumbnails { opts.thumbnails = thumbnails(doc, &opts).map_err(|e| pdf_error(cmd, e))?; } - let mut warnings = vec![]; - // The status bar shows the first warning, so this one goes ahead of the flattening note. + let mut notes = vec![]; + // A loss, ahead of the flattening note: the PDF/X pages leave out the bleed that Document + // Setup gives. if opts.settings.leaves_out_bleed(doc) { - warnings.push(BLEED_LEFT_OUT.to_string()); + notes.push(Note::loss(BLEED_LEFT_OUT)); } if opts.settings.preserve_editing { if opts.artboards.as_ref().is_none_or(|v| v.iter().copied().eq(0..doc.artboards.len())) { opts.native = Some(native()?); if let Some((data, lost)) = private()? { opts.private_data = Some(data); - warnings.extend(lost); + notes.extend(lost.into_iter().map(Note::loss)); } } else { opts.settings.preserve_editing = false; - warnings.push(EDITING_NEEDS_EVERY_ARTBOARD.to_string()); + notes.push(Note::setting(EDITING_NEEDS_EVERY_ARTBOARD)); } } let set = &opts.settings; @@ -370,12 +372,13 @@ fn encode_carrying_at( vectorcraft_doc::cancel::check()?; if flattened { let kind = if set.standard.flattens() { set.standard.label() } else { set.compatibility.label() }; - warnings.push(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset")); + notes.push(Note::loss(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset"))); } let r = super::super::rasterfx::export_pdf_with_report(flat.as_ref().unwrap_or(doc), &opts).map_err(|e| pdf_error(cmd, e))?; - warnings.extend(r.warnings); - warnings.extend(crate::cmd::fonts::substitution_warning(doc)); - Ok((r.bytes, warnings)) + notes.extend(r.warnings.into_iter().map(Note::loss)); + notes.extend(r.setting_notes.into_iter().map(Note::setting)); + notes.extend(crate::cmd::fonts::substitution_warning(doc).map(Note::loss)); + Ok((r.bytes, notes)) } /// The flattener preset of PDF 1.3 files whose `flattenerPreset` is empty. @@ -479,8 +482,8 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> { } else { q = json!({ "_displayDocTitle": display_doc_title }); } - let (bytes, warnings) = encode(C, titled.as_ref().unwrap_or(&st.doc), &q)?; - write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": warnings })) + let (bytes, notes) = encode(C, titled.as_ref().unwrap_or(&st.doc), &q)?; + write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": note_texts(¬es) })) } /// `(path, value)` for every leaf of `v` that differs from `default` (`compression.color.ppi`). @@ -512,7 +515,7 @@ fn pdf_settings(s: &mut Session, p: &Value) -> Result<Value> { let set = settings_with(Q, p, saved)?; let diff = changes(&set, &preset_settings(Q, p, saved)?); let warnings = match s.active() { - Some(st) if bool_or(p, "includeDocument", false) => encode(Q, &st.doc, &*expand_preset(s, Q, p)?)?.1, + Some(st) if bool_or(p, "includeDocument", false) => note_texts(&encode(Q, &st.doc, &*expand_preset(s, Q, p)?)?.1), _ => set.warnings(), }; let v = serde_json::to_value(&set).map_err(|e| EngineError::Other(e.to_string()))?; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 5d7783665b..c21b53da4f 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -740,12 +740,14 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res carries_editing.set(true); Ok(Some((data, resolved_warnings))) }; - let (bytes, mut warnings) = super::pdf::encode_carrying(cmd, &pages, ¶ms, native, private)?; - warnings.extend(placed_warnings); + let (bytes, notes) = super::pdf::encode_carrying(cmd, &pages, ¶ms, native, private)?; + let mut enc = Encoded::one_with_notes(bytes, notes); + enc.warnings.extend(placed_warnings); + // A note on the options: `pdfCompatible: false` writes blank pages. if !pdf_compatible { - warnings.push(if carries_editing.get() { NOT_PDF_COMPATIBLE_EDITING } else { NOT_PDF_COMPATIBLE }.to_string()); + enc.setting_notes.push(if carries_editing.get() { NOT_PDF_COMPATIBLE_EDITING } else { NOT_PDF_COMPATIBLE }.to_string()); } - Ok(Encoded { warnings, ..Encoded::one(bytes) }) + Ok(enc) } /// Snapshot the active document for `plan` (stamping File Info's dates on the snapshot when the @@ -833,8 +835,8 @@ impl SaveJob { } /// Encode the file → it (and, with `separateArtboards`, a file per artboard after it), with - /// the warnings (what the format loses first, then the encoder's own notes: PDF options not - /// applied yet…, then the plan's notes on its params). + /// the notes (what the format loses first, then the encoder's own notes: PDF options not + /// applied yet…, then the plan's notes on its params, which are notes on the options). pub fn encode(&self) -> Result<Encoded> { let mut enc = self.encode_one(&self.doc)?; for &b in &self.boards { @@ -842,7 +844,7 @@ impl SaveJob { enc.files.extend(one.files.into_iter().map(|(_, bytes)| (Some(b), bytes))); } enc.warnings.splice(0..0, self.format_losses()); - enc.warnings.extend(self.plan.notes.iter().cloned()); + enc.setting_notes.extend(self.plan.notes.iter().cloned()); Ok(enc) } @@ -893,9 +895,8 @@ impl SaveJob { /// order: when `stop` returns a value, nothing is written and that value is returned. pub fn write_unless(&self, stop: impl FnOnce(&[&str]) -> Option<Value>, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> { let (plan, doc) = (&self.plan, &*self.doc); - let mut enc = self.encode()?; - let warnings = std::mem::take(&mut enc.warnings); - let extra = json!({ "format": plan.format.id, "warnings": warnings }); + let enc = self.encode()?; + let extra = json!({ "format": plan.format.id, "warnings": enc.notes() }); let Some(path) = &plan.path else { let mut out = write_encoded(None, &plan.name, doc, &enc, extra)?; out["name"] = json!(plan.name); diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index b48aef68c3..62096a6f5e 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -17,12 +17,12 @@ use vectorcraft_doc::Document; use vectorcraft_geom::Rect; use super::super::{EngineError, NodeId, Result, Session, bad, bool_or, str_param}; -use super::encode::single_artboard; +use super::encode::{add_note, single_artboard}; use super::export::{export_crop, isolated, selected_artwork, without_artboards}; use super::naming::{self, NameParts}; use super::{ - ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names_keeping_dots, create_dir, distinct_names_by, encode_with_warnings, merge, - unique_file_names_keeping_dots, with_black_output, writable, write_file, + ARTBOARD_PARAMS, ArtboardPick, Format, Note, artboard_file_names_keeping_dots, create_dir, distinct_names_by, encode_with_notes, merge, + note_texts, unique_file_names_keeping_dots, with_black_output, writable, write_file, }; const C: &str = "document.exportForScreens"; @@ -1046,7 +1046,8 @@ pub(crate) fn with_export_preset(s: &Session, cmd: &str, settings: &mut Map<Stri /// holds its artboard alone), or with `fullDocument` one file per row (a PDF of every artboard, /// else the bounds of all art). Items that share a name get `-2`, `-3`… instead of overwriting, and /// so do different files that would share a name, with or without the `name` pattern. The reply's -/// `warnings` lists every warning the encoders give for the files, each once. +/// `warnings` lists every warning the encoders give for the files, each once, what the files lose +/// first ([`super::note_texts`]). pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> { check_passwords(p)?; let o = ScreenParams::of(p)?; @@ -1138,18 +1139,16 @@ fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) -> create_dir(dir)?; } let mut out = Output::default(); - // The encoders' warnings for every file, each once. - let mut warnings: Vec<String> = Vec::new(); + // The encoders' notes for every file, each once. + let mut notes: Vec<Note> = Vec::new(); // Include Bleed: a grown artboard already holds the bleed. Assets, layers and a selection are // written on a page of their own bounds, which it doesn't grow. let bleed_on_page = o.include_bleed && o.assets.is_none() && o.layers.is_none() && o.selection.is_none(); let mut write = |doc: &Document, format: &str, options: &Value| -> Result<Vec<u8>> { // The steps are the files: an encoder's own steps (artboards, pages) are not reported. - let (bytes, notes) = vectorcraft_doc::cancel::quiet(|| encode_with_warnings(doc, format, options))?; - for w in notes { - if !(bleed_on_page && w == super::pdf::BLEED_LEFT_OUT) && !warnings.contains(&w) { - warnings.push(w); - } + let (bytes, given) = vectorcraft_doc::cancel::quiet(|| encode_with_notes(doc, format, options))?; + for n in given.into_iter().filter(|n| !(bleed_on_page && n.text == super::pdf::BLEED_LEFT_OUT)) { + add_note(&mut notes, n); } Ok(bytes) }; @@ -1224,6 +1223,8 @@ fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) -> if o.zip || folder.is_none() { vectorcraft_doc::cancel::check()?; } + // What the files lose first, across every file. + let warnings = note_texts(¬es); let names: Vec<&str> = out.kept.iter().map(|(n, _)| n.as_str()).collect(); if o.zip { let zip = super::zip::deflate(&out.kept, super::zip::FIXED_TIME, vectorcraft_doc::cancel::is_cancelled); diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index 3123fdcfb1..d479be0f7c 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -198,6 +198,7 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco enc.warnings.push(w); } } + enc.add_setting_notes(out.setting_notes); enc.files.push((artboard, if compressed { vectorcraft_svg::compress_bytes(&bytes) } else { bytes })); } // Live type names its fonts; outlines and embedded fonts are the fallback font's. diff --git a/crates/engine/src/cmd/fileio/tests_dxf.rs b/crates/engine/src/cmd/fileio/tests_dxf.rs index 54839edda4..03273d9450 100644 --- a/crates/engine/src/cmd/fileio/tests_dxf.rs +++ b/crates/engine/src/cmd/fileio/tests_dxf.rs @@ -194,6 +194,21 @@ fn placed_images_and_fill_hatches_can_be_left_out() { let _ = std::fs::remove_dir_all(dir); } +/// The reply gives what the drawings lose before a note on the options, across the files: the +/// first artboard's drawing notes the fill hatches that `includeHatches: false` leaves out, and +/// the second's also has a see-through rectangle that DXF 2000 writes opaque. +#[test] +fn what_the_drawings_lose_comes_before_what_an_option_left_out() { + let mut s = two_boards(); + s.execute("object.setProps", &json!({"opacity": 50})).unwrap(); + let r = s.execute("document.export", &json!({"format": "dxf", "version": "2000", "includeHatches": false, "useArtboards": true})).unwrap(); + let warnings: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + let Some((option, losses)) = warnings.split_last() else { panic!("no notes") }; + assert!(option.starts_with("fill hatches are left out"), "{warnings:?}"); + assert!(losses.iter().any(|w| w.starts_with("transparency needs DXF 2004")), "{warnings:?}"); + assert!(!losses.iter().any(|w| w.starts_with("fill hatches")), "each note once: {warnings:?}"); +} + /// Two placed images whose pixel data can't be decoded are left out of the drawing and get no /// linked file, and `warnings` holds the note raster exports give, once, without naming either /// image's key. diff --git a/crates/engine/src/cmd/fileio/tests_exportas.rs b/crates/engine/src/cmd/fileio/tests_exportas.rs index 09bbcf28f3..f3566e82ba 100644 --- a/crates/engine/src/cmd/fileio/tests_exportas.rs +++ b/crates/engine/src/cmd/fileio/tests_exportas.rs @@ -117,3 +117,35 @@ fn formats_list_use_artboards() { } assert!(opts("vectorcraft").get("useArtboards").is_none()); } + +/// An export's reply gives what the file loses before its notes on the options, whatever order +/// the encoder gave them: the WebP encoder notes that lossy WebP isn't applied before the export +/// notes the type it drew in the fallback font. With only notes on the options, they keep their +/// order. +#[test] +fn what_the_file_loses_comes_before_a_note_on_the_options() { + let lossy = "lossy WebP isn't available yet: the file is lossless (exact, but larger)"; + let warnings = |s: &mut Session, p: Value| -> Vec<String> { + let r = s.execute("document.export", &p).unwrap(); + r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_string).collect() + }; + let mut s = session(60.0, 40.0, 1); + assert_eq!(warnings(&mut s, json!({"format": "webp", "lossless": false})), [lossy]); + s.execute("text.create", &json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap(); + let w = warnings(&mut s, json!({"format": "webp", "lossless": false})); + assert_eq!(w.len(), 2, "{w:?}"); + assert!(w[0].contains("fallback font") && w[0].contains("Nonexistent Sans Probe"), "{w:?}"); + assert_eq!(w[1], lossy); + // document.serialize gives them in the same order. + let r = s.execute("document.serialize", &json!({"format": "webp", "lossless": false})).unwrap(); + assert_eq!(r["warnings"], json!(w)); +} + +#[test] +fn note_texts_give_the_losses_first_each_kind_in_order() { + use super::encode::{Note, note_texts}; + let notes = [Note::setting("s1"), Note::loss("l1"), Note::setting("s2"), Note::loss("l2")]; + assert_eq!(note_texts(¬es), ["l1", "l2", "s1", "s2"]); + assert_eq!(note_texts(&[Note::setting("s1"), Note::setting("s2")]), ["s1", "s2"]); + assert!(note_texts(&[]).is_empty()); +} diff --git a/crates/engine/src/cmd/fileio/tests_pdfsecurity.rs b/crates/engine/src/cmd/fileio/tests_pdfsecurity.rs index d8c36a9196..09505391af 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfsecurity.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfsecurity.rs @@ -56,3 +56,21 @@ fn permissions_without_a_password_warn_and_a_password_applies_them() { let bytes = vectorcraft_format::base64_decode(locked["dataBase64"].as_str().unwrap()).unwrap(); assert!(String::from_utf8_lossy(&bytes).contains("/Encrypt")); } + +/// The writer notes the permissions it didn't apply before the export notes the type it drew in +/// the fallback font: the reply gives what the file loses first, through both commands. +#[test] +fn the_fallback_font_note_comes_before_the_permissions_note() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap(); + s.execute("text.create", &json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap(); + for r in [ + s.execute("document.exportPdf", &json!({"security": {"printing": "none"}})).unwrap(), + s.execute("document.export", &json!({"format": "pdf", "security": {"printing": "none"}})).unwrap(), + ] { + let warnings: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + let at = |s: &str| warnings.iter().position(|w| w.contains(s)); + let (font, permissions) = (at("Nonexistent Sans Probe"), at("permissions need a password")); + assert!(font.is_some() && permissions.is_some() && font < permissions, "{warnings:?}"); + } +} diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index 86ee164998..0378478522 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -507,6 +507,22 @@ fn type_in_a_missing_family_is_named_once_for_every_format() { assert!(fallback[0].contains("Nonexistent Sans Probe"), "{fallback:?}"); } +/// What a later file loses comes before a note on the options that an earlier file gave: the +/// first artboard's WebP gives only the lossy WebP note, the second's also says its type doesn't +/// fit. +#[test] +fn what_any_file_loses_comes_before_the_notes_on_the_options() { + let mut s = session(2); + let second = s.doc().unwrap().doc.artboards[1].rect; + let area = json!({"width": 30, "height": 8}); + s.execute("text.create", &json!({"x": second.x0 + 2.0, "y": second.y0 + 2.0, "size": 12, "area": area, "text": "Ab\nCd\nEf\nGh"})).unwrap(); + let r = export(&mut s, json!({"formats": [{"format": "webp", "lossless": false}]})); + let warnings: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + assert_eq!(warnings.len(), 2, "{warnings:?}"); + assert!(warnings[0].contains("doesn't fit its frame"), "{warnings:?}"); + assert!(warnings[1].starts_with("lossy WebP"), "{warnings:?}"); +} + #[test] fn full_document_gives_one_file_per_format() { let mut s = session(3); diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index 566a16dbfd..8f5f532c51 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -645,8 +645,8 @@ pub fn export_pdf(doc: &Document, opts: &vectorcraft_pdf::PdfOptions) -> std::re export_pdf_with_report(doc, opts).map(|r| r.bytes) } -/// Like [`export_pdf`], also returning the writer's warnings (options not applied yet, features -/// approximated or left out). +/// Like [`export_pdf`], also returning the writer's warnings (features approximated or left out) +/// and its notes on the options (options not applied yet). pub fn export_pdf_with_report( doc: &Document, opts: &vectorcraft_pdf::PdfOptions, diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 2a09cf003c..993fcbd9e3 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -156,6 +156,8 @@ pub enum HiddenObjects { pub struct LegacyAiOutput { pub bytes: Vec<u8>, pub warnings: Vec<String>, + /// Notes on what the options asked for: hidden objects left out or written visible. + pub setting_notes: Vec<String>, } /// Whether image node `n` of `doc` is written as a 1-bit image mask in its colorize color @@ -267,7 +269,7 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result<LegacyAiO // The page trailer as the specification's script has it (§3); the document trailer takes the // prolog's dictionary off the dictionary stack (its procedure set termination, §3). s.push_str("%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\nend\n%%EOF\n"); - Ok(LegacyAiOutput { bytes: s.into_bytes(), warnings: w.warnings }) + Ok(LegacyAiOutput { bytes: s.into_bytes(), warnings: w.warnings, setting_notes: w.setting_notes }) } #[cfg(test)] diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index d189c94ed9..67ae552d99 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -1733,12 +1733,12 @@ fn hidden_objects_go_to_a_hidden_layer_above_their_own() { #[test] fn hidden_objects_can_be_left_out_or_shown() { let out = write_with(&with_hidden(), LegacyAiOptions { hidden: HiddenObjects::Omit, ..LegacyAiOptions::default() }); - assert!(out.warnings.iter().any(|w| w == HIDDEN), "{:?}", out.warnings); + assert!(out.setting_notes.iter().any(|w| w == HIDDEN), "{:?}", out.setting_notes); let doc = crate::import(&out.bytes).unwrap().document; assert_eq!(doc.layers.len(), 2); assert_eq!(children(&children(&doc.layers[0])[0]).len(), 1); let out = write_with(&with_hidden(), LegacyAiOptions { hidden: HiddenObjects::Visible, ..LegacyAiOptions::default() }); - assert!(out.warnings.iter().any(|w| w == HIDDEN_SHOWN), "{:?}", out.warnings); + assert!(out.setting_notes.iter().any(|w| w == HIDDEN_SHOWN), "{:?}", out.setting_notes); let doc = crate::import(&out.bytes).unwrap().document; assert_eq!(doc.layers.len(), 2); let pair = children(&children(&doc.layers[0])[0]); diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 032317ab7a..1cdd4b2bdf 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -24,6 +24,8 @@ pub(super) struct Writer<'a> { /// The name each spot color (by its swatch's name) is written with ([`Self::spot`]). spot_names: Vec<(String, String)>, pub(super) warnings: Vec<String>, + /// Notes on what the options asked for ([`crate::LegacyAiOutput::setting_notes`]). + pub(super) setting_notes: Vec<String>, /// The lock state the last `A` set. locked: bool, /// Symbols and live objects being written inside one another. @@ -56,6 +58,7 @@ impl<'a> Writer<'a> { spots: vec![], spot_names: vec![], warnings: vec![], + setting_notes: vec![], locked: false, nest: 0, hidden: o.hidden, @@ -68,12 +71,21 @@ impl<'a> Writer<'a> { } } + /// Note `w`, once, about something of the document that the file leaves out or that reaches + /// it changed. A note about an option goes to [`Self::note_setting`]. fn warn(&mut self, w: &str) { if !self.warnings.iter().any(|x| x == w) { self.warnings.push(w.to_string()); } } + /// Note `w`, once, about hidden objects left out or written visible as an option asks. + fn note_setting(&mut self, w: &str) { + if !self.setting_notes.iter().any(|x| x == w) { + self.setting_notes.push(w.to_string()); + } + } + fn line(&mut self, s: &str) { self.body.push_str(s); self.body.push('\n'); @@ -225,7 +237,7 @@ impl<'a> Writer<'a> { if !n.visible && !self.in_hidden { match self.hidden { HiddenObjects::Omit => { - self.warn(HIDDEN); + self.note_setting(HIDDEN); return; } HiddenObjects::Layer => { @@ -233,7 +245,7 @@ impl<'a> Writer<'a> { self.hidden_art.push((n.clone(), locked || n.locked)); return; } - HiddenObjects::Visible => self.warn(HIDDEN_SHOWN), + HiddenObjects::Visible => self.note_setting(HIDDEN_SHOWN), } } if n.opacity < 1.0 || n.blend != BlendMode::Normal || n.extras().mask.as_ref().is_some_and(|m| !m.disabled) { diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index 586967234b..f6b7fba609 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -195,6 +195,8 @@ pub struct EpsOutput { pub bytes: Vec<u8>, /// What was approximated or left out. pub warnings: Vec<String>, + /// Notes on the options: the preview or thumbnail asked for that was left out. + pub setting_notes: Vec<String>, } /// The `%%BoundingBox` of `region` with PostScript's origin at document point `origin`: whole @@ -229,11 +231,11 @@ pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumb let baked = vectorcraft_effects::bake_document(doc); let doc = baked.as_ref().unwrap_or(doc); let page = scene::Scene::new(doc, opts).run(); - let mut warnings = page.warnings; + let mut setting_notes = vec![]; let thumb = thumbnail.and_then(|t| match tiff::png(t) { Ok(png) => Some(png), Err(e) => { - warnings.push(format!("the thumbnail was left out: {e}")); + setting_notes.push(format!("the thumbnail was left out: {e}")); None } }); @@ -241,7 +243,7 @@ pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumb let bytes = match (opts.preview, preview) { (Preview::None, _) => ps.into_bytes(), (_, None) => { - warnings.push("the preview was left out: nothing was rendered for it".into()); + setting_notes.push("the preview was left out: nothing was rendered for it".into()); ps.into_bytes() } (kind, Some(img)) => { @@ -249,7 +251,7 @@ pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumb dos_eps(ps.as_bytes(), &tiff)? } }; - Ok(EpsOutput { bytes, warnings }) + Ok(EpsOutput { bytes, warnings: page.warnings, setting_notes }) } /// Comment lines that start a data section the file carries (`%VectorCraft_BeginData: <name>`) diff --git a/crates/eps/src/print.rs b/crates/eps/src/print.rs index 99dffd8225..0ccd7a6331 100644 --- a/crates/eps/src/print.rs +++ b/crates/eps/src/print.rs @@ -181,7 +181,7 @@ pub fn print(pages: &[PrintPage], order: &[usize], job: &PrintJob) -> Result<Eps s.push_str(page); } s.push_str("%%Trailer\n%%EOF\n"); - Ok(EpsOutput { bytes: s.into_bytes(), warnings }) + Ok(EpsOutput { bytes: s.into_bytes(), warnings, setting_notes: vec![] }) } #[cfg(test)] diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index db9ae2837c..e289b516ab 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -976,7 +976,7 @@ fn a_tiff_preview_sits_behind_the_binary_header() { } // Asked for but not rendered: the file has no preview, and says so. let out = export(&d, &EpsOptions { preview: Preview::TiffColor, ..opts(&d) }, None, None).unwrap(); - assert!(out.bytes.starts_with(b"%!PS") && out.warnings.iter().any(|w| w.contains("preview"))); + assert!(out.bytes.starts_with(b"%!PS") && out.setting_notes.iter().any(|w| w.contains("preview"))); } #[test] diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index 788cacea3a..eabba52d9e 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -7,8 +7,9 @@ pub fn export(doc: &Document, opts: &PdfOptions) -> Result<Vec<u8>, PdfError> { export_with_report(doc, opts).map(|r| r.bytes) } -/// Like [`export`], also returning warnings: options accepted but not applied yet (see -/// [`crate::PdfSettings::warnings`]) and features approximated or dropped. +/// Like [`export`], also returning warnings, features approximated or dropped, and notes on the +/// options: options accepted but not applied yet (see [`crate::PdfSettings::warnings`]) or applied +/// another way. pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportReport, PdfError> { // Type in the document's composite fonts is drawn in them, whether or not an app has the document open. let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); @@ -63,9 +64,10 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha let display_doc_title = opts.display_doc_title || opts.title.as_deref().is_some_and(|title| !title.trim().is_empty()); let mut w = Writer::new(doc, set, &title, created_at, doc.color_mode == vectorcraft_doc::ColorMode::Cmyk)?; // Preserve Editing: the native document as an embedded file (`check` refused PDF/A with it). - let mut warnings = set.warnings(); + let mut warnings = vec![]; + let mut settings = set.warnings(); if raised.is_some() { - warnings.push(if opts.settings.compatibility == Compatibility::Pdf14 { + settings.push(if opts.settings.compatibility == Compatibility::Pdf14 { "PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6".into() } else { "PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6".into() @@ -75,7 +77,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha if let Some(native) = native { w.pdf.document_mut().embed_file(crate::editing::embedded_file(native, set.compression.compress_text, now.map(date_time))); } else if set.preserve_editing { - warnings.push("Preserve editing needs the native document, which wasn't given: the PDF reopens as plain artwork".into()); + settings.push("Preserve editing needs the native document, which wasn't given: the PDF reopens as plain artwork".into()); } let mut ex = Exporter::new(doc, set); @@ -85,7 +87,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha // A knockout page group draws the layers' objects one by one, not layer by layer. ex.layers = set.writes_layers() && !doc.page_knockout; if set.writes_layers() && doc.page_knockout { - warnings.push("PDF layers can't be written with a knockout page group: every layer is plain page content".into()); + settings.push("PDF layers can't be written with a knockout page group: every layer is plain page content".into()); } ex.overprint = set.advanced.overprint == crate::Overprint::Preserve; let bleed = set.bleed_of(doc); @@ -142,7 +144,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; let (layers, overprinted, invisible) = (ex.layers, ex.overprinted, ex.invisible); w.absorb(ex); - let (bytes, more) = match w.finish(left_out, display_doc_title) { + let (bytes, more, catalog) = match w.finish(left_out, display_doc_title) { Ok(done) => done, Err(e) => return Err(writer_error(doc, opts, left_out, &long, e)), }; @@ -155,11 +157,13 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha // The writer's metadata has no copyright: it is added to the XMP packet. let bytes = crate::post::rights(bytes, &doc.metadata)?; warnings.extend(more); + settings.extend(catalog); let bytes = crate::post::user_units(bytes, &units)?; // A PDF 1.5 file written as PDF 1.6 keeps the encryption PDF 1.5 has (128-bit RC4). - let bytes = crate::post::finish(bytes, opts, native.is_some(), &mut warnings)?; + let bytes = crate::post::finish(bytes, opts, native.is_some(), &mut settings)?; warnings.dedup(); - Ok(ExportReport { bytes, warnings }) + settings.dedup(); + Ok(ExportReport { bytes, warnings, setting_notes: settings }) } /// The artboards `opts` exports, in page order, with the boxes of their pages: Marks and Bleeds diff --git a/crates/pdf/src/export/writer.rs b/crates/pdf/src/export/writer.rs index 027025ad2c..9f6d9d6906 100644 --- a/crates/pdf/src/export/writer.rs +++ b/crates/pdf/src/export/writer.rs @@ -26,6 +26,10 @@ pub(crate) struct Writer { images: Vec<(String, Image)>, } +/// What [`Writer::finish`] gives: the file, the warnings of its drawing, and the notes on the +/// output intent asked for ([`crate::ExportReport::setting_notes`]). +pub(crate) type Finished = (Vec<u8>, Vec<String>, Vec<String>); + /// One page as [`Writer::page`] draws it: the art placed in a drawing space ([`Self::place`]), /// which [`Self::view`] maps onto the page. pub(crate) struct Sheet<'a> { @@ -252,10 +256,11 @@ impl Writer { } /// The finished file (with the output intent and Trapped entries, and checked against its - /// standard) and the warnings of its drawing. PDF/X-1a spot colours keep their CMYK + /// standard), the warnings of its drawing, and the notes on the output intent asked for + /// ([`crate::ExportReport::setting_notes`]). PDF/X-1a spot colours keep their CMYK /// alternates. When the writer couldn't decode one of the images, its key is added to /// `left_out` ([`leaving_out_undecodable`]) and the writer's error is returned. - pub(crate) fn finish(mut self, left_out: &mut HashSet<String>, display_doc_title: bool) -> Result<(Vec<u8>, Vec<String>), PdfError> { + pub(crate) fn finish(mut self, left_out: &mut HashSet<String>, display_doc_title: bool) -> Result<Finished, PdfError> { let images = std::mem::take(&mut self.images); let mut bytes = self.pdf.finish().map_err(|e| { if let write::WriteError::Serialization(k) = &e @@ -270,10 +275,9 @@ impl Writer { } let lab = !self.lab_spots.is_empty() && !self.out.standard.cmyk_only(); let bytes = if lab { crate::lab_spot::lab_alternates(bytes, &self.lab_spots) } else { bytes }; - let (bytes, more) = self.out.write_catalog(bytes, display_doc_title)?; - self.warnings.extend(more); + let (bytes, settings) = self.out.write_catalog(bytes, display_doc_title)?; self.warnings.extend(self.rgb_in_cmyk.warning()); - Ok((crate::pdfx::finish(bytes, self.out.standard, self.pdf13)?, self.warnings)) + Ok((crate::pdfx::finish(bytes, self.out.standard, self.pdf13)?, self.warnings, settings)) } } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 234f4b8a8d..2ba025866d 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -213,7 +213,10 @@ pub struct ImportReport { #[derive(Clone, Debug)] pub struct ExportReport { pub bytes: Vec<u8>, + /// What the file leaves out of the document or writes differently. pub warnings: Vec<String>, + /// Notes on the options: one the export didn't apply or applied another way. + pub setting_notes: Vec<String>, } #[derive(Debug, thiserror::Error, PartialEq)] diff --git a/crates/pdf/src/output.rs b/crates/pdf/src/output.rs index 55ab42ced3..77bc7d1d66 100644 --- a/crates/pdf/src/output.rs +++ b/crates/pdf/src/output.rs @@ -349,7 +349,7 @@ impl ColorOut { } /// `pdf` with the output intent and Trapped entries, and a PDF/X file's identification - /// ([`crate::pdfx`]) → (the file, warnings). + /// ([`crate::pdfx`]) → (the file, notes on the output intent asked for). pub(crate) fn write_catalog(&self, pdf: Vec<u8>, display_doc_title: bool) -> Result<(Vec<u8>, Vec<String>), PdfError> { let cat = self.catalog.as_ref(); if cat.is_none() && !display_doc_title { diff --git a/crates/pdf/src/post.rs b/crates/pdf/src/post.rs index e735d079fe..c6fdd8048d 100644 --- a/crates/pdf/src/post.rs +++ b/crates/pdf/src/post.rs @@ -204,13 +204,13 @@ fn linearised(pdf: &[u8], set: &PdfSettings) -> Result<Vec<u8>, PdfError> { } /// The file the export wrote (`pdf`), finished as `opts` say; `editing`: it carries the editing -/// data, to seal once the pages are final. What can't be done comes back in `warnings`. -pub(crate) fn finish(pdf: Vec<u8>, opts: &PdfOptions, editing: bool, warnings: &mut Vec<String>) -> Result<Vec<u8>, PdfError> { +/// data, to seal once the pages are final. The options it can't apply come back in `settings`. +pub(crate) fn finish(pdf: Vec<u8>, opts: &PdfOptions, editing: bool, settings: &mut Vec<String>) -> Result<Vec<u8>, PdfError> { let set = &opts.settings; let pdf = match (set.thumbnails, opts.thumbnails.is_empty()) { (true, false) => thumbnails(pdf, &opts.thumbnails, set.standard.is_pdfx())?, (true, true) => { - warnings.push("page thumbnails need the pages drawn, which weren't given: none are embedded".into()); + settings.push("page thumbnails need the pages drawn, which weren't given: none are embedded".into()); pdf } (false, _) => pdf, @@ -229,7 +229,7 @@ pub(crate) fn finish(pdf: Vec<u8>, opts: &PdfOptions, editing: bool, warnings: & match linearised(&pdf, set) { Ok(out) => Ok(out), Err(e) => { - warnings.push(format!("the file isn't optimized for fast web view: {e}")); + settings.push(format!("the file isn't optimized for fast web view: {e}")); crate::encrypt::protect(pdf, set) } } diff --git a/crates/pdf/src/print.rs b/crates/pdf/src/print.rs index b11a764be2..0bad9d9f60 100644 --- a/crates/pdf/src/print.rs +++ b/crates/pdf/src/print.rs @@ -470,10 +470,10 @@ fn print_inner(doc: &Document, opts: &PrintOptions, left_out: &mut HashSet<Strin for ex in exporters { w.absorb(ex); } - let (bytes, more) = w.finish(left_out, false)?; + let (bytes, more, settings) = w.finish(left_out, false)?; let bytes = crate::forms::finish(bytes, doc, &pdf, false, overprinted, invisible)?; let mut warnings = plan.warnings; - for m in more { + for m in more.into_iter().chain(settings) { if !warnings.contains(&m) { warnings.push(m); } diff --git a/crates/pdf/src/tests/editing.rs b/crates/pdf/src/tests/editing.rs index 9cc77ff689..c4f2900709 100644 --- a/crates/pdf/src/tests/editing.rs +++ b/crates/pdf/src/tests/editing.rs @@ -118,7 +118,8 @@ fn the_legacy_name_is_read_and_plain_pdfs_have_no_editing_data() { assert_eq!(editing(&plain), None); assert_eq!(import_with_report(&plain, &ImportOptions::default()).unwrap().native, None); let r = pdf_with(None, true); - assert!(r.warnings.len() == 1 && r.warnings[0].contains("Preserve editing"), "{:?}", r.warnings); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.setting_notes.len() == 1 && r.setting_notes[0].contains("Preserve editing"), "{:?}", r.setting_notes); assert_eq!(editing(&r.bytes), None); } diff --git a/crates/pdf/src/tests/layers.rs b/crates/pdf/src/tests/layers.rs index cb5bf9a12e..0650e3f0ef 100644 --- a/crates/pdf/src/tests/layers.rs +++ b/crates/pdf/src/tests/layers.rs @@ -212,15 +212,15 @@ fn without_pdf_layers_the_layers_are_plain_page_content() { oc_properties(&r.bytes, None, |props, _| has = props.is_some()); assert_eq!(has, v["createLayers"] == json!(true) && warning.is_none(), "{v}"); match warning { - Some(w) => assert!(r.warnings.iter().any(|x| x.contains(w)), "{v}: {:?}", r.warnings), - None => assert!(r.warnings.is_empty(), "{v}: {:?}", r.warnings), + Some(w) => assert!(r.setting_notes.iter().any(|x| x.contains(w)), "{v}: {:?}", r.setting_notes), + None => assert!(r.warnings.is_empty() && r.setting_notes.is_empty(), "{v}: {:?} {:?}", r.warnings, r.setting_notes), } } // A knockout page group draws objects, not layers. d.page_knockout = true; let r = export(&d, json!({"createLayers": true})); oc_properties(&r.bytes, None, |props, _| assert!(props.is_none())); - assert!(r.warnings.iter().any(|w| w.contains("knockout page group")), "{:?}", r.warnings); + assert!(r.setting_notes.iter().any(|w| w.contains("knockout page group")), "{:?}", r.setting_notes); } #[test] diff --git a/crates/pdf/src/tests/marks.rs b/crates/pdf/src/tests/marks.rs index 603138bd49..655ed43ea4 100644 --- a/crates/pdf/src/tests/marks.rs +++ b/crates/pdf/src/tests/marks.rs @@ -251,8 +251,9 @@ fn every_preset_and_version_keeps_a_20_m_page_or_says_why_not() { assert!(r.bytes.starts_with(format!("%PDF-{header}").as_bytes()), "{}", c.label()); assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), [3.938], "{}", c.label()); assert!((raw_meters(&r.bytes).0 - 20.0).abs() < 0.001, "{}", c.label()); - let raised = r.warnings.iter().any(|w| w.starts_with("PDF 1.5 has no UserUnit") && w.ends_with("the file is written as PDF 1.6")); - assert_eq!(raised, c == Compatibility::Pdf15, "{}: {:?}", c.label(), r.warnings); + let raised = + r.setting_notes.iter().any(|w| w.starts_with("PDF 1.5 has no UserUnit") && w.ends_with("the file is written as PDF 1.6")); + assert_eq!(raised, c == Compatibility::Pdf15, "{}: {:?}", c.label(), r.setting_notes); } } } @@ -288,11 +289,11 @@ fn pdfa2b_at_pdf_1_4_writes_a_long_page_as_pdf_1_6_within_14400_units() { assert!(media[2] - media[0] <= 14_400.0 && media[3] - media[1] <= 14_400.0, "{side} {in_points}: {media:?}"); assert!(((media[2] - media[0]) * unit[0] - side).abs() < side * 0.001, "{side} {in_points}: {media:?} {unit:?}"); let raised = "PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6"; - assert!(r.warnings.iter().any(|w| w == raised), "{side} {in_points}: {:?}", r.warnings); + assert!(r.setting_notes.iter().any(|w| w == raised), "{side} {in_points}: {:?}", r.setting_notes); } let r = report(&sized(14_400.0, 300.0), set.clone()).unwrap(); assert!(r.bytes.starts_with(b"%PDF-1.4"), "{in_points}"); - assert!(!r.warnings.iter().any(|w| w.contains("has no UserUnit")), "{in_points}: {:?}", r.warnings); + assert!(!r.setting_notes.iter().any(|w| w.contains("has no UserUnit")), "{in_points}: {:?}", r.setting_notes); } } @@ -500,7 +501,7 @@ fn long_pages_in_points_keeps_every_version_and_changes_nothing_where_pages_are_ assert!(r.bytes.starts_with(format!("%PDF-{}", c.id()).as_bytes()), "{}", c.label()); assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), Vec::<f64>::new(), "{}", c.label()); assert!((raw_meters(&r.bytes).0 - 10.0).abs() < 0.001, "{}", c.label()); - assert!(!r.warnings.iter().any(|w| w.starts_with("PDF 1.5 has no UserUnit")), "{}: {:?}", c.label(), r.warnings); + assert!(!r.setting_notes.iter().any(|w| w.starts_with("PDF 1.5 has no UserUnit")), "{}: {:?}", c.label(), r.setting_notes); assert_eq!(long_page_warnings(&r).len(), 1, "{}: {:?}", c.label(), r.warnings); if matches!(c, Compatibility::Pdf13 | Compatibility::Pdf14) { assert_eq!(r.bytes, report(&d, set).unwrap().bytes, "{}", c.label()); diff --git a/crates/pdf/src/tests/output.rs b/crates/pdf/src/tests/output.rs index f7732821c1..0ab4d58c18 100644 --- a/crates/pdf/src/tests/output.rs +++ b/crates/pdf/src/tests/output.rs @@ -463,7 +463,7 @@ fn pdf_a_keeps_its_own_output_intent() { let r = pdf(&d, json!({"standard": "pdfA2b", "output": {"outputIntent": GENERIC_CMYK, "trapped": true}})); let text = String::from_utf8_lossy(&r.bytes); assert!(text.contains("GTS_PDFA1") && !text.contains("GTS_PDFX") && !text.contains("/Trapped")); - assert!(r.warnings.iter().any(|w| w.contains("PDF/A")), "{:?}", r.warnings); + assert!(r.setting_notes.iter().any(|w| w.contains("PDF/A")), "{:?}", r.setting_notes); } #[test] diff --git a/crates/pdf/src/tests/pdf13.rs b/crates/pdf/src/tests/pdf13.rs index 4df56f3ff0..af7bbefe5d 100644 --- a/crates/pdf/src/tests/pdf13.rs +++ b/crates/pdf/src/tests/pdf13.rs @@ -69,7 +69,7 @@ fn pdf_1_3_files_are_encrypted_with_40_bit_rc4() { let o = options(json!({"compatibility": "1.3", "security": {"openPassword": "open", "permissionsPassword": "owner", "plaintextMetadata": true}})); let r = export_with_report(&doc(1.0), &o).unwrap(); assert!(String::from_utf8_lossy(&r.bytes).contains("/Filter/Standard/V 1/R 2/Length 40/P ")); - assert!(r.warnings.iter().any(|w| w.contains("PDF 1.3 encryption covers the metadata too")), "{:?}", r.warnings); + assert!(r.setting_notes.iter().any(|w| w.contains("PDF 1.3 encryption covers the metadata too")), "{:?}", r.setting_notes); assert!(matches!(import(&r.bytes), Err(PdfError::NeedsPassword))); for password in ["open", "owner"] { let opened = import_with_report(&r.bytes, &ImportOptions { password: Some(password.into()), ..Default::default() }).unwrap(); diff --git a/crates/pdf/src/tests/print.rs b/crates/pdf/src/tests/print.rs index 2f59563e70..897e7241b4 100644 --- a/crates/pdf/src/tests/print.rs +++ b/crates/pdf/src/tests/print.rs @@ -349,7 +349,7 @@ fn every_copy_on_a_sheet_draws_the_same_symbol_instances() { w.page(&mut ex, &sheet).unwrap(); assert_eq!(ex.symbol_limit, 10, "the limit is the export's again after the sheet"); w.absorb(ex); - let (bytes, _) = w.finish(&mut std::collections::HashSet::new(), false).unwrap(); + let (bytes, ..) = w.finish(&mut std::collections::HashSet::new(), false).unwrap(); let p = pages(&bytes); let left = p[0].1.iter().filter(|(r, _)| r.x0 < 200.0).count(); assert_eq!((left, p[0].1.len() - left), (3, 3), "one instance in each copy: {:?}", p[0].1); diff --git a/crates/pdf/src/tests/realtext.rs b/crates/pdf/src/tests/realtext.rs index b3e105e4f6..3db63a4b2a 100644 --- a/crates/pdf/src/tests/realtext.rs +++ b/crates/pdf/src/tests/realtext.rs @@ -689,7 +689,7 @@ fn subset_thresholds_below_100_percent_are_accepted_with_a_warning() { let d = doc(vec![TextObject::point(Point::new(20.0, 60.0), "Subset", style(24.0))]); let settings: PdfSettings = serde_json::from_value(json!({"advanced": {"outlineText": false, "fontSubsetPercent": 40}})).unwrap(); let r = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap(); - assert!(r.warnings.iter().any(|w| w.contains("subset")), "{:?}", r.warnings); + assert!(r.setting_notes.iter().any(|w| w.contains("subset")), "{:?}", r.setting_notes); assert_eq!(texts(&import_as(&r.bytes, TextAs::Text)), ["Subset"]); } diff --git a/crates/pdf/src/tests/settings.rs b/crates/pdf/src/tests/settings.rs index 1750beebd2..e159b7b162 100644 --- a/crates/pdf/src/tests/settings.rs +++ b/crates/pdf/src/tests/settings.rs @@ -73,7 +73,7 @@ fn checks_refuse_what_the_writer_cant_honour() { } #[test] -fn options_not_applied_yet_come_back_as_warnings() { +fn options_not_applied_yet_come_back_as_setting_notes() { assert!(PdfSettings::default().warnings().is_empty()); for (v, word) in [ (json!({"createLayers": true, "compatibility": "1.4"}), "PDF 1.5"), @@ -110,7 +110,8 @@ fn options_not_applied_yet_come_back_as_warnings() { let r = export_with_report(&doc(), &PdfOptions { settings: settings(json!({"thumbnails": true, "marks": {"trim": true}})), ..Default::default() }) .unwrap(); - // The writer embeds the thumbnails it is given: without them, the one warning says so. - assert!(r.warnings.len() == 1 && r.warnings[0].contains("thumbnails need the pages drawn"), "{:?}", r.warnings); + // The writer embeds the thumbnails it is given: without them, the one note on the options says so. + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.setting_notes.len() == 1 && r.setting_notes[0].contains("thumbnails need the pages drawn"), "{:?}", r.setting_notes); assert!(r.bytes.starts_with(b"%PDF-1.7")); } diff --git a/crates/pdf/src/tests/webview.rs b/crates/pdf/src/tests/webview.rs index a40db425e7..6fc50d0d54 100644 --- a/crates/pdf/src/tests/webview.rs +++ b/crates/pdf/src/tests/webview.rs @@ -345,7 +345,7 @@ fn thumbnails_are_embedded_on_every_page() { assert_eq!(import(&r.bytes).unwrap().artboards.len(), 3); // Asked for without the pages drawn: none, with a warning. Thumbnails too large are refused. let r = export_with_report(&d, &options(json!({"thumbnails": true}))).unwrap(); - assert!(r.warnings.iter().any(|w| w.contains("thumbnails")) && crate::lab_spot::find(&r.bytes, b"/Thumb", 0).is_none()); + assert!(r.setting_notes.iter().any(|w| w.contains("thumbnails")) && crate::lab_spot::find(&r.bytes, b"/Thumb", 0).is_none()); o.thumbnails[1] = thumb(107, 20, 0); assert!(matches!(export(&d, &o), Err(PdfError::BadSetting(_)))); } diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 8eb18626f3..667e32a4ca 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -102,6 +102,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: region_boxes: None, art_reach: HashMap::new(), warnings: vec![], + setting_notes: vec![], id_prefix, linked: Vec::new(), instance_xf: Affine::IDENTITY, @@ -137,7 +138,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: // XML names cannot use character references. Preserve names that Latin-1 cannot encode. let encoding = if opts.encoding == Encoding::Latin1 && w.non_latin1_names { - w.warn("Unicode data attribute names require UTF-8; SVG was written in UTF-8"); + w.note_setting("Unicode data attribute names require UTF-8; SVG was written in UTF-8"); Encoding::Utf8 } else { opts.encoding @@ -223,7 +224,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: out.push_str(&w.body); out.push_str("</svg>"); out.push_str(nl); - Output { svg: out, linked: w.linked, warnings: w.warnings, encoding } + Output { svg: out, linked: w.linked, warnings: w.warnings, setting_notes: w.setting_notes, encoding } } type ColorizedKey = (String, u32, u32, Option<[u8; 3]>); @@ -268,6 +269,8 @@ struct Writer<'a> { brushes: Option<Vec<vectorcraft_brush::Brush>>, /// Features written approximately (once each). warnings: Vec<String>, + /// Notes on how the options were applied (once each). + setting_notes: Vec<String>, /// Prepended to every id and class name ([`ObjectIds::Unique`]). id_prefix: &'a str, /// Image files the SVG links to ([`ImageMode::Link`]). diff --git a/crates/svg/src/export/output.rs b/crates/svg/src/export/output.rs index 7c30f23f5a..150c3605d5 100644 --- a/crates/svg/src/export/output.rs +++ b/crates/svg/src/export/output.rs @@ -525,11 +525,21 @@ impl Writer<'_> { }) } + /// Note `w`, once, about something of the document that the file leaves out or that reaches + /// it changed ([`crate::Output::warnings`]). A note about an option goes to [`Self::note_setting`]. pub(super) fn warn(&mut self, w: &str) { if !self.warnings.iter().any(|x| x == w) { self.warnings.push(w.to_string()); } } + + /// Note `w`, once, about an option that wasn't applied or was applied another way while the + /// art reaches the file as it looks ([`crate::Output::setting_notes`]). + pub(super) fn note_setting(&mut self, w: &str) { + if !self.setting_notes.iter().any(|x| x == w) { + self.setting_notes.push(w.to_string()); + } + } } /// An element that has both its own classes and a generated `cls-N` would write `class` twice: diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index e5a5248400..27893bbe74 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -521,7 +521,7 @@ impl Writer<'_> { continue; }; if !font.subset { - self.warn(&format!("the font {name} is embedded whole: its licence doesn't allow subsetting")); + self.note_setting(&format!("the font {name} is embedded whole: its licence doesn't allow subsetting")); } let shared = uses.iter().filter(|o| described(o) == described(u)).count() > 1; let range = if shared { format!(";unicode-range:{}", unicode_range(&u.chars)) } else { String::new() }; diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 8a447f6c38..ef6a312fe5 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -294,6 +294,9 @@ pub struct Output { pub linked: Vec<LinkedImage>, /// Features the export approximates. pub warnings: Vec<String>, + /// Notes on the options: one the export didn't apply or applied another way (an encoding, a + /// font embedded whole). + pub setting_notes: Vec<String>, /// The encoding [`Self::bytes`] writes. pub encoding: Encoding, } @@ -361,10 +364,11 @@ pub fn export(doc: &Document, opts: &ExportOptions) -> String { export_full(doc, opts, None).svg } -/// Export a document as an SVG string, also returning warnings about approximated features. +/// Export a document as an SVG string, also returning warnings about approximated features, +/// then the notes on the options ([`Output::setting_notes`]). pub fn export_with_report(doc: &Document, opts: &ExportOptions) -> (String, Vec<String>) { let o = export_full(doc, opts, None); - (o.svg, o.warnings) + (o.svg, o.warnings.into_iter().chain(o.setting_notes).collect()) } /// Export a document as SVG plus the image files it links to. `native`: the document in the diff --git a/crates/svg/tests/it/data_attributes.rs b/crates/svg/tests/it/data_attributes.rs index d652b9665f..f8151ed7da 100644 --- a/crates/svg/tests/it/data_attributes.rs +++ b/crates/svg/tests/it/data_attributes.rs @@ -98,7 +98,7 @@ fn unicode_data_names_survive_each_encoding() { } if encoding == Encoding::Latin1 { assert_eq!(out.encoding, Encoding::Utf8, "XML names cannot use character references"); - assert!(!out.warnings.is_empty()); + assert!(!out.setting_notes.is_empty()); assert!(!out.svg.contains("ISO-8859-1")); } else { assert_eq!(out.encoding, encoding); diff --git a/crates/svg/tests/it/encoding_profile_fonts.rs b/crates/svg/tests/it/encoding_profile_fonts.rs index f0ea9804d0..5468953aa4 100644 --- a/crates/svg/tests/it/encoding_profile_fonts.rs +++ b/crates/svg/tests/it/encoding_profile_fonts.rs @@ -321,7 +321,7 @@ fn font_licences_are_respected() { let size = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf").len(); assert!(fonts[0].2.len() + 64 >= size, "embedded whole: {} of {size} bytes", fonts[0].2.len()); assert!(o.warnings.iter().any(|w| w.contains("Sealed Sans 3") && w.contains("doesn't allow embedding")), "{:?}", o.warnings); - assert!(o.warnings.iter().any(|w| w.contains("Wholed Sans 3") && w.contains("whole")), "{:?}", o.warnings); + assert!(o.setting_notes.iter().any(|w| w.contains("Wholed Sans 3") && w.contains("whole")), "{:?}", o.setting_notes); } proptest::proptest! { diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index dff4fa0dd2..b43193d148 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -155,6 +155,23 @@ mod tests { assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes))); } + /// An export whose encoder notes an option it didn't apply (lossy WebP) before what the file + /// loses (type in a font that isn't installed): the status bar names what the file loses, and + /// the hover lists it first. + #[test] + fn an_export_names_what_the_file_loses_before_a_note_on_the_options() { + let (mut app, _) = app(); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("text.create", json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap(); + let r = export(&mut app, Some("webp"), Some("/out/d.webp".into()), &json!({"lossless": false})).unwrap(); + let notes = notes_of(&r); + assert_eq!(notes.len(), 2, "{r}"); + assert!(notes[0].contains("Nonexistent Sans Probe"), "{r}"); + assert!(notes[1].starts_with("lossy WebP"), "{r}"); + assert_eq!(app.ui.status, exported_with_notes("/out/d.webp", 2, ¬es[0])); + assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes))); + } + /// #1685: a save or an export that can't write its file says so in a dialog that names the /// file and the reason and stays until it is closed; the document stays modified and keeps /// the file it had. An agent's request only gets the error, and a message that arrives while diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs index 03e0196267..10eee5800c 100644 --- a/crates/ui-egui/src/io/export.rs +++ b/crates/ui-egui/src/io/export.rs @@ -83,7 +83,7 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin return Ok(json!({ "replaces": replaced })); } let files = write_encoded(write, &doc, &path, &enc)?; - let mut out = json!({ "path": files.first().unwrap_or(&path), "warnings": enc.warnings }); + let mut out = json!({ "path": files.first().unwrap_or(&path), "warnings": enc.notes() }); if files.len() > 1 { out["files"] = json!(files); } diff --git a/docs/mcp.md b/docs/mcp.md index fa22c66414..5c93f5dc5e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -773,7 +773,11 @@ a path whose extension names another format has it replaced by the template's extension, and any other path that does not end in that extension has it added, so `card.png` is saved as `card.vctemplate` and `card` as `card.vctemplate`; the suggested name and the Templates folder come back without a path), `file.newFromTemplate {path}`, `file.revert` and `file.formatOptions {format?}` (a format's options -with the values a save would use). Without a path the save commands return `{dataBase64, name, folder?, warnings}`. A document saved as SVG or PDF +with the values a save would use). Without a path the save commands return `{dataBase64, name, folder?, warnings}`. +In the replies of the save and export commands, and of `clipboard.exportPdf` and `clipboard.exportPng`, `warnings` +lists what the file leaves out of the document or writes differently first. The notes on the options follow: an +option not applied or applied another way while the art reaches the file as it looks, or something an option turns +off, such as `includeImages: false` in DXF. Each kind keeps the order the writers give it. A document saved as SVG or PDF remembers its options, so the next save reuses them; a document not yet saved as a native file is offered the VectorCraft Options the last native Save or Save As used (not its artboards, nor Use Compression). A PDF opened with `pages`, `cropTo` or `password` is only part of the file, so Save asks for a new name instead of writing it back. @@ -885,7 +889,7 @@ Long-tail commands: PDF files take the Save PDF dialog's options: `preset`, `standard`, `compatibility`, the General toggles and the `compression`, `marks`, `bleed`, `output`, `advanced` and `security` sections (`list_commands` with filter `exportPdf` documents every field). `document.exportPdf` and `export` (format `pdf`, the options in `options`) return `warnings`: -options accepted but not applied yet, and features approximated or left out. An image whose pixel data can't be +features approximated or left out, then options accepted but not applied yet. An image whose pixel data can't be decoded (a PNG cut short) is left out of the pages, and `warnings` holds the note "an image couldn't be decoded and is missing from the picture" once, as raster exports give it; `.ai` saves and `file.print` do the same. A standard with a PDF version it doesn't allow is refused (PDF/A-2b at 1.3 or 2.0, PDF/X-4 above 1.6, PDF/X-1a and PDF/X-3 above 1.4); @@ -6290,7 +6294,8 @@ the later file is written as `-2`, `-3`… (`Artboard-1-2.png`) and listed under rows write one file, and row folders that differ only in case (`Print`, `print`) are one folder, spelled as the first row spells it. The document remembers the last params: `document.exportSettings`. As in `document.export`, the reply's `warnings` lists the notes of every file exported (an image that couldn't be decoded, type written in the -fallback font…), each note once; the export still writes or returns every file. +fallback font…), each note once, what any of the files loses before the notes on the options; the export still +writes or returns every file. Batch exports (#1935): - **What:** `layers: [id…]` (each layer's art alone) or `selection: "one" | "each"` instead of artboards diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d42a4d988b..87b21331f2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -580,6 +580,7 @@ since 2026-10-01. - **Clipped bottom-bar text (#6622):** A Help Bar hint wider than the window, such as the Dimension tool's hint in an 800-point window, ends in an ellipsis, as the status message does. In a narrow status bar the tool name ends in an ellipsis and leaves the status message its room. Resting the pointer on a clipped hint, tool name or message shows the full text in a tooltip that wraps within the window. Text that fits shows no tooltip, and screen readers receive the full text. Scores and estimates are unchanged. - **Notes of an open in the status bar:** After File › Open the status message names the first note that says reading the file left something out (the notes that make Save ask before writing over the file), else the first note, and counts them all. With several notes, resting the pointer on the message lists them in that order, as many as fit in the window and at most 30, then how many more. Screen readers receive the list after the message. Scores and estimates are unchanged. - **Notes of a save or an export in the status bar:** After Save, Save As, Save a Copy, Save as Template, Save a Timestamped Copy, Save as PDF, Export As, Export Selection, Export for Screens, Asset Export, Save for Web, Preview in Browser, Save for Office Documents, Text Export Options, a print to a file or the CSS Properties panel's export, the status message names the first note and counts them all. With several notes, resting the pointer on the message lists them in the order the reply gives them (a save's notes start with what the format loses), as it lists an open's notes. Scores and estimates are unchanged. +- **What a save or an export loses, named first:** Each writer marks its notes as a loss (something of the document the file leaves out or writes differently) or a note on the options (an option it didn't apply or applied another way while the art reaches the file as it looks, or something an option turns off). The replies of the save and export commands list the losses first, each kind in the order given, across every file an export writes. The status message after a save or an export then names a loss when there is one: an export to lossy WebP of type in a font that isn't installed names the fallback-font note instead of the lossy WebP note. A print to a file keeps the order its notes are given in. Scores and estimates are unchanged. - **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. From cc761c359ed233f21bddc5449b10e90ad3b1b45d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 08:35:11 -0800 Subject: [PATCH 50/92] Color: a recent color drags into the Swatches panel to become a swatch A Recent Colors chip in the Color or Swatches panel now starts a paint drag. Dropped on the Swatches panel's list it becomes a swatch of its color, in its color model, through swatch.new as one undo step: on a color group or one of its swatches it joins that group, elsewhere it joins the ungrouped swatches. A click on a chip still paints the selected art, and a drag does not. A paint Alt-dropped on Registration now becomes a new swatch instead of ending in a "can't be edited" error from swatch.edit. --- crates/ui-egui/src/panels.rs | 6 +- crates/ui-egui/src/panels/swatches/dnd.rs | 17 ++- crates/ui-egui/src/panels/swatches/tests.rs | 139 +++++++++++++++++++- crates/ui-egui/src/widgets/drag.rs | 19 +-- docs/target-app-parity.md | 1 + 5 files changed, 160 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 899f026398..cbad3dd34f 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -528,7 +528,8 @@ pub(crate) fn label_or_name(s: &str, built_in: bool) -> &str { /// "Recent Colors" header + rows of chips (the Session's recent colours, which every paint /// command feeds), wrapped onto as many rows as the panel's width needs; returns the one clicked, -/// for the caller to apply. +/// for the caller to apply. A chip drags its color as a solid paint, which the Swatches panel +/// makes a swatch of. pub(crate) fn recent_colors_row(app: &VectorcraftApp, ui: &mut Ui) -> Option<Color> { let t = Tokens::get(ui.ctx()); crate::widgets::subheader(ui, tl!("Recent Colors")); @@ -543,7 +544,8 @@ pub(crate) fn recent_colors_row(app: &VectorcraftApp, ui: &mut Ui) -> Option<Col for (i, c) in colors.iter().enumerate() { let (row, col) = (i / per_row, i % per_row); let cell = Rect::from_min_size(r.min + vec2(3.0 + col as f32 * 19.0, 3.0 + row as f32 * 19.0), vec2(16.0, 16.0)); - let resp = ui.interact(cell, ui.scope_id().with(("recent", i)), Sense::click()); + let resp = ui.interact(cell, ui.scope_id().with(("recent", i)), Sense::click_and_drag()); + crate::widgets::drag_source(ui, &resp, || crate::widgets::PanelDrag::paint(Paint::solid(*c))); crate::widgets::swatch_tile(ui, cell, &Paint::solid(*c), false, resp.hovered()); if resp.on_hover_text(c.to_hex()).clicked() { chosen = Some(*c); diff --git a/crates/ui-egui/src/panels/swatches/dnd.rs b/crates/ui-egui/src/panels/swatches/dnd.rs index 22b542a3f0..d4360b4743 100644 --- a/crates/ui-egui/src/panels/swatches/dnd.rs +++ b/crates/ui-egui/src/panels/swatches/dnd.rs @@ -59,9 +59,10 @@ pub(super) enum Drop { /// `target` of its kind (neither None, Registration nor a tint swatch; see [`replaces`]), it /// gives that swatch its paint instead of moving. Move { names: Vec<String>, target: Option<String>, after: bool, with: Option<String> }, - /// A paint from elsewhere (a Fill/Stroke proxy, the Gradient panel's thumbnail) dropped on row - /// `target` (or between rows) becomes a swatch; with Alt held (`replace`) it replaces swatch - /// `target` when that is of its kind and not a tint swatch. + /// A paint from elsewhere (a Fill/Stroke proxy, a Recent Colors chip, the Gradient panel's + /// thumbnail) dropped on row `target` (or between rows) becomes a swatch; with Alt held + /// (`replace`) it replaces swatch `target` when that is of its kind and neither Registration + /// nor a tint swatch. New { paint: Paint, target: Option<String>, replace: bool }, /// Art dragged off the canvas becomes a pattern swatch made of a copy of it (the art stays). Pattern { ids: Vec<u64> }, @@ -259,7 +260,9 @@ pub(super) fn apply_drop(app: &mut VectorcraftApp, drop: Drop) { None => return, }, // Alt: an edited gradient (or colour) replaces the swatch it is dropped on. - Drop::New { paint, target: Some(t), replace: true } if d.swatch(&t).is_some_and(|w| w.tint_of().is_none() && same_kind(&w.paint, &paint)) => { + Drop::New { paint, target: Some(t), replace: true } + if d.swatch(&t).is_some_and(|w| !w.is_reserved() && w.tint_of().is_none() && same_kind(&w.paint, &paint)) => + { ("swatch.edit", json!({"name": t, "paint": crate::panels::swatch_paint_params(&paint)})) } Drop::New { paint, target, .. } => { @@ -279,9 +282,9 @@ pub(super) fn apply_drop(app: &mut VectorcraftApp, drop: Drop) { } /// While a panel drags something onto art, a chip follows the pointer: the dragged paint (swatches, -/// a Fill/Stroke proxy, the Gradient panel's thumbnail), the fill of the object whose appearance -/// the Appearance panel's thumbnail carries (or of the first art dragged off the canvas), or the -/// thumbnail of a dragged graphic style. +/// a Fill/Stroke proxy, a Recent Colors chip, the Gradient panel's thumbnail), the fill of the +/// object whose appearance the Appearance panel's thumbnail carries (or of the first art dragged +/// off the canvas), or the thumbnail of a dragged graphic style. pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) { let Some(d) = egui::DragAndDrop::payload::<PanelDrag>(ctx) else { return }; let fill_of = |id: &vectorcraft_doc::NodeId| app.session.active().and_then(|st| st.doc.node(*id)).map(|n| n.appearance.fill_paint()); diff --git a/crates/ui-egui/src/panels/swatches/tests.rs b/crates/ui-egui/src/panels/swatches/tests.rs index c649e282ba..3586f91607 100644 --- a/crates/ui-egui/src/panels/swatches/tests.rs +++ b/crates/ui-egui/src/panels/swatches/tests.rs @@ -437,11 +437,24 @@ fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, from: Pos2, to: Pos2, tim /// [`drag`] with `modifiers` held. fn drag_with(app: &mut VectorcraftApp, ctx: &egui::Context, from: Pos2, to: Pos2, modifiers: Modifiers, time: f64) { + drag_drawn(app, ctx, from, to, modifiers, time, show); +} + +/// [`drag_with`] over the panels `draw` draws. +fn drag_drawn( + app: &mut VectorcraftApp, + ctx: &egui::Context, + from: Pos2, + to: Pos2, + modifiers: Modifiers, + time: f64, + draw: fn(&mut VectorcraftApp, &mut Ui), +) { let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }; - frame_with(app, ctx, vec![Event::PointerMoved(from), button(from, true)], modifiers, time, show); - frame_with(app, ctx, vec![Event::PointerMoved(from + vec2(6.0, 4.0))], modifiers, time + 0.1, show); - frame_with(app, ctx, vec![Event::PointerMoved(to)], modifiers, time + 0.2, show); - frame_with(app, ctx, vec![Event::PointerMoved(to), button(to, false)], modifiers, time + 0.3, show); + frame_with(app, ctx, vec![Event::PointerMoved(from), button(from, true)], modifiers, time, draw); + frame_with(app, ctx, vec![Event::PointerMoved(from + vec2(6.0, 4.0))], modifiers, time + 0.1, draw); + frame_with(app, ctx, vec![Event::PointerMoved(to)], modifiers, time + 0.2, draw); + frame_with(app, ctx, vec![Event::PointerMoved(to), button(to, false)], modifiers, time + 0.3, draw); } fn names_of(app: &VectorcraftApp, group: Option<&str>) -> Vec<String> { @@ -553,6 +566,124 @@ fn a_proxy_dropped_on_the_panel_becomes_a_swatch() { assert!(egui::DragAndDrop::payload::<PanelDrag>(&ctx).is_none()); } +/// The center of the Recent Colors chip `i` drawn by `draw` in a frame at `time`: the chip of the +/// panel's row, or (`color`) of the Color panel's row, drawn in a child whose scope id `draw` keeps +/// in the panel state `"test-color-scope"`. +fn recent_chip(app: &mut VectorcraftApp, ctx: &egui::Context, i: usize, time: f64, draw: fn(&mut VectorcraftApp, &mut Ui), color: bool) -> Pos2 { + let mut chip = None; + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0))); + let mut out = ctx.run_ui(egui::RawInput { time: Some(time), screen_rect, ..Default::default() }, |ui| { + draw(app, ui); + let scope = if color { pstate::<Option<egui::Id>>(ctx, "test-color-scope").expect("the Color panel is drawn") } else { ui.scope_id() }; + chip = ctx.read_response(scope.with(("recent", i))).map(|r| r.rect.center()); + }); + out.textures_delta.clear(); + chip.unwrap_or_else(|| panic!("recent chip {i} is drawn")) +} + +/// A rectangle filled with a CMYK color and then an RGB one, so Recent Colors holds the RGB color +/// first and the CMYK one second; → its id and the CMYK color. +fn art_with_two_recent_colors(app: &mut VectorcraftApp) -> (vectorcraft_doc::NodeId, Color) { + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("paint.setFill", json!({"ids": [id], "color": {"c": 10, "m": 60, "y": 20, "k": 5}, "keepModel": true})).unwrap(); + app.run("paint.setFill", json!({"ids": [id], "color": "#2050a0"})).unwrap(); + let cmyk = app.session.recent_colors[1]; + assert!(matches!(cmyk, Color::Cmyk { .. }), "the second recent color is CMYK: {cmyk:?}"); + (vectorcraft_doc::NodeId(id), cmyk) +} + +/// The fill of art `id`. +fn fill_of(app: &VectorcraftApp, id: vectorcraft_doc::NodeId) -> Option<Paint> { + app.session.doc().unwrap().doc.node(id).map(|n| n.appearance.fill_paint()) +} + +/// A Recent Colors chip dragged onto the swatch list becomes a swatch of its color, in its color +/// model, as one undo step; the drag paints no art, and a color that is already a swatch is added +/// again under another name. +#[test] +fn a_recent_color_dragged_onto_the_list_becomes_a_swatch() { + let mut app = app(); + let ctx = context(); + let (id, cmyk) = art_with_two_recent_colors(&mut app); + frame(&mut app, &ctx, vec![], 0.0, show); + let art = fill_of(&app, id); + let undo = app.session.doc().unwrap().history.undo.len(); + let (count, brights) = (app.session.doc().unwrap().doc.swatches_iter().count(), names_of(&app, Some("Brights")).len()); + // Onto a grouped swatch: into that group, at its end. + let chip = recent_chip(&mut app, &ctx, 1, 0.5, show, false); + drag(&mut app, &ctx, chip, tile_center(&ctx, "Bright Red"), 1.0); + let d = app.session.doc().unwrap(); + let group = &d.doc.swatch_groups.iter().find(|g| g.name == "Brights").unwrap().swatches; + assert_eq!(group.len(), brights + 1, "{}", app.ui.status); + let first = group.last().cloned().expect("a swatch"); + assert_eq!(first.paint, Paint::solid(cmyk), "the chip's color, still CMYK"); + assert_eq!(d.history.undo.len(), undo + 1, "one undo step"); + assert_eq!(fill_of(&app, id), art, "the drag paints no art"); + assert!(egui::DragAndDrop::payload::<PanelDrag>(&ctx).is_none()); + // The same color again, onto an ungrouped swatch: a second swatch under another name. + frame(&mut app, &ctx, vec![], 2.0, show); + let chip = recent_chip(&mut app, &ctx, 1, 2.5, show, false); + drag(&mut app, &ctx, chip, tile_center(&ctx, "Red"), 3.0); + let newest = app.session.doc().unwrap().doc.swatches.last().cloned().expect("a swatch"); + assert_eq!(newest.paint, Paint::solid(cmyk)); + assert_ne!(newest.name, first.name, "another name"); + assert_eq!(app.session.doc().unwrap().doc.swatches_iter().count(), count + 2); + // Undo takes the last one off. + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.swatches_iter().count(), count + 1); +} + +/// A color Alt-dropped on Registration, which can't be edited, becomes a new swatch and leaves +/// Registration as it is. +#[test] +fn a_color_alt_dropped_on_registration_becomes_a_new_swatch() { + let mut app = app(); + let ctx = context(); + let (_, cmyk) = art_with_two_recent_colors(&mut app); + frame(&mut app, &ctx, vec![], 0.0, show); + let registration = app.session.doc().unwrap().doc.swatch(REGISTRATION).map(|w| w.paint.clone()); + let count = app.session.doc().unwrap().doc.swatches_iter().count(); + let chip = recent_chip(&mut app, &ctx, 1, 0.5, show, false); + drag_with(&mut app, &ctx, chip, tile_center(&ctx, REGISTRATION), Modifiers::ALT, 1.0); + let d = &app.session.doc().unwrap().doc; + assert_eq!(d.swatches_iter().count(), count + 1, "{}", app.ui.status); + assert_eq!(d.swatches.last().map(|w| w.paint.clone()), Some(Paint::solid(cmyk))); + assert_eq!(d.swatch(REGISTRATION).map(|w| w.paint.clone()), registration, "Registration is unchanged"); +} + +/// A Recent Colors chip of the Color panel dragged onto the Swatches panel becomes a swatch; a click +/// on it still paints the selected art. +#[test] +fn a_recent_color_dragged_from_the_color_panel_becomes_a_swatch() { + fn both(app: &mut VectorcraftApp, ui: &mut Ui) { + show(app, ui); + ui.push_id("color", |ui| { + crate::panels::color::show(app, ui); + set_pstate(ui.ctx(), "test-color-scope", Some(ui.scope_id())); + }); + } + let mut app = app(); + let ctx = context(); + app.ui.color_show_recent_colors = true; + let (id, cmyk) = art_with_two_recent_colors(&mut app); + frame(&mut app, &ctx, vec![], 0.0, both); + let count = app.session.doc().unwrap().doc.swatches_iter().count(); + let art = fill_of(&app, id); + let chip = recent_chip(&mut app, &ctx, 1, 0.5, both, true); + drag_drawn(&mut app, &ctx, chip, tile_center(&ctx, "Red"), Modifiers::NONE, 1.0, both); + let d = &app.session.doc().unwrap().doc; + assert_eq!(d.swatches_iter().count(), count + 1, "{}", app.ui.status); + assert_eq!(d.swatches.last().map(|w| w.paint.clone()), Some(Paint::solid(cmyk))); + assert_eq!(fill_of(&app, id), art, "the drag paints no art"); + // A click on the chip paints the selected art. + app.run("select.set", json!({"ids": [id.0]})).unwrap(); + frame(&mut app, &ctx, vec![], 2.0, both); + let chip = recent_chip(&mut app, &ctx, 1, 2.5, both, true); + let button = |pressed| Event::PointerButton { pos: chip, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + frame(&mut app, &ctx, vec![Event::PointerMoved(chip), button(true), button(false)], 3.0, both); + assert_eq!(fill_of(&app, id).and_then(|f| f.color()), Some(cmyk), "a click paints the art"); +} + #[test] fn alt_dropping_a_gradient_on_a_gradient_swatch_replaces_it() { let mut app = app(); diff --git a/crates/ui-egui/src/widgets/drag.rs b/crates/ui-egui/src/widgets/drag.rs index 1564025a41..f130795c0f 100644 --- a/crates/ui-egui/src/widgets/drag.rs +++ b/crates/ui-egui/src/widgets/drag.rs @@ -4,12 +4,13 @@ use super::{Paint, Rect, Response, Stroke, StrokeKind, Tokens, Ui, Vec2}; /// handler for all of them (`canvas::panel_drop`); a chip of a dragged paint follows the pointer. #[derive(Clone, Debug, PartialEq)] pub enum PanelDrag { - /// A paint: Swatches panel tiles, a Fill/Stroke proxy or the Gradient panel's thumbnail. Art it - /// is dropped on takes `params` (`{swatch}`, `{color}`, `{gradient}`…; null for a colour group, - /// which paints nothing) through the active proxy's `paint.setFill`/`paint.setStroke`; the - /// Gradient panel's ramp takes its colour as a stop; the Swatches panel moves `rows`, or makes - /// a swatch of a paint dragged from elsewhere. `library`: the Libraries panel colour it is - /// (library id, colour name), which that panel moves to where it is dropped. + /// A paint: Swatches panel tiles, a Fill/Stroke proxy, a Recent Colors chip or the Gradient + /// panel's thumbnail. Art it is dropped on takes `params` (`{swatch}`, `{color}`, `{gradient}`…; + /// null for a colour group, which paints nothing) through the active proxy's + /// `paint.setFill`/`paint.setStroke`; the Gradient panel's ramp takes its colour as a stop; the + /// Swatches panel moves `rows`, or makes a swatch of a paint dragged from elsewhere. `library`: + /// the Libraries panel colour it is (library id, colour name), which that panel moves to where + /// it is dropped. Paint { paint: Paint, params: serde_json::Value, rows: Option<SwatchRows>, library: Option<(String, String)> }, /// The Appearance panel's thumbnail: art it is dropped on takes object `0`'s appearance /// (`appearance.copyFrom`); the Graphic Styles panel makes a style of it. @@ -68,9 +69,9 @@ pub struct SwatchRows { } impl PanelDrag { - /// `paint` dragged from a Fill/Stroke proxy or the Gradient panel's thumbnail. A gradient - /// carries no placement: it fits the art it lands on (freeform points are placed afresh there, - /// coloured like the dragged ones). + /// `paint` dragged from a Fill/Stroke proxy, a Recent Colors chip or the Gradient panel's + /// thumbnail. A gradient carries no placement: it fits the art it lands on (freeform points are + /// placed afresh there, coloured like the dragged ones). pub fn paint(mut paint: Paint) -> Self { if let Paint::Gradient(g) = &mut paint { g.geom = None; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b172aefd7e..9ab066adca 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1324,6 +1324,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Plain Comma and Period, and also Shift+Comma and Shift+Period, apply the last solid color and the last gradient to the active Fill or Stroke proxy in VectorCraft ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), "Select color fill mode" and "Select gradient fill mode"). Recent Colors keeps the last 30 solid colors applied, newest first and each once, and the list stays the same when another document is opened or made active; a 31st color drops the oldest ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). The list lasts for the session. In the Color and Swatches panels the chips wrap onto further rows when the panel is too narrow for one row (`paint.recent`). Tests: `recent_colors_keep_thirty_colors_across_documents` in `crates/engine/src/tests/paintproxy.rs`, `all_thirty_recent_colors_wrap_into_a_narrow_panel` in `crates/ui-egui/src/panels/color/tests.rs`. Unchecking Show Recent Colors in the Color panel's menu hides its Recent Colors row, and checking it shows the row again ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). In VectorCraft the item is checked by default and kept between sessions, applied colors still join the list while the row is hidden, Hide Options hides the row either way, and the Swatches panel keeps its row (`ui.colorPanel {recentColors}`). Tests: `show_recent_colors_hides_and_shows_the_row` in `crates/ui-egui/src/panels/color/tests.rs`. + A Recent Colors chip in the Color or Swatches panel drags into the Swatches panel's list to become a swatch of its color, in its color model, as one undo step ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). In VectorCraft a chip dropped on a color group or one of its swatches joins that group, anywhere else in the list it joins the ungrouped swatches at the end, a color that is already a swatch is added again under another name, and dragging a chip does not paint the selected art (`swatch.new`). Tests: `a_recent_color_dragged_onto_the_list_becomes_a_swatch` and `a_recent_color_dragged_from_the_color_panel_becomes_a_swatch` in `crates/ui-egui/src/panels/swatches/tests.rs`. A Cmd/Ctrl-click on the Color panel's spectrum applies Complement and a Cmd/Ctrl+Shift-click applies Invert to the active Fill or Stroke (`paint.complement`, `paint.invert`); with Alt also held, the click recolors the inactive one, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) list them. View › Proof Setup › Color blindness – Tritanopia-type turns Proof Colors on and proofs the canvas with the tritanopia simulation of Brettel, Viénot and Mollon (1997). The Separations Preview panel's Proof Setup list offers the same target (`view.proofSetup {target: "tritanopia"}`). Grays keep their value. The Separations Preview panel's Gamut Check button reports one color outside the CMYK gamut in the singular and more in the plural, in English and in each language's plural forms (`gamut_check_status_takes_the_plural_form` in `crates/ui-egui/src/i18n/tests.rs`). From 66f6be77eed52f4d5d066539fc5f8b1905e94812 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 08:05:32 -0800 Subject: [PATCH 51/92] .ai save: RGB and named colors keep their CMYK equivalents when a reopened file is saved The .ai writers write RGB and Lab values to four decimals next to a CMYK equivalent, but converted that equivalent from the color at full precision. When a file was read back and saved again, its CMYK equivalents differed in the fourth decimal (for example 0.6831 and 0.6832). In the editing data this affected plain RGB colors, RGB gradient stops, global process and spot colors and the header's custom colors; in the legacy .ai writer it affected RGB gradient stops. The CMYK equivalent of an RGB color, and of a global process or spot color, is now converted from the values as they are written, so both saves convert the same values. Lab values of global process and spot colors are now written clamped to the range the reader reads (L 0 to 100, a and b -128 to 127). A plain Lab fill or stroke and a Lab gradient stop are still written as RGB with a CMYK equivalent converted from the Lab values. The reopened file holds them as RGB, so their CMYK equivalents change once on its next save. The art style round-trip test now compares the program exactly; its blanked CMYK values and the four-decimal tolerance are no longer needed. --- crates/eps/src/ai_editing/tests.rs | 84 ++++++++++++---------- crates/eps/src/ai_editing/writer.rs | 19 +++-- crates/eps/src/ai_editing/writer/paints.rs | 11 +-- crates/eps/src/ai_ops.rs | 12 ++++ crates/eps/src/legacy_ai/tests.rs | 22 ++++++ crates/eps/src/legacy_ai/writer.rs | 3 +- crates/eps/src/ps.rs | 5 ++ 7 files changed, 106 insertions(+), 50 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 6baf4e802b..a486feb039 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -294,6 +294,51 @@ fn writing_again_gives_the_same_bytes() { assert!(first.warnings.is_empty(), "{:?}", first.warnings); } +/// The CMYK equivalent written beside RGB or Lab values is the one of the values as written (to four +/// decimals, Lab clamped to the range a reader reads), so the reopened file is written with the same +/// numbers: a new document's swatches, and colors of many decimals in fills, gradient stops, global +/// process swatches (RGB and Lab), an RGB spot swatch (in a fill and in a stop), Lab spot swatches +/// and a Lab spot swatch out of range. +#[test] +fn cmyk_equivalents_are_written_again_unchanged() { + let mut doc = Document::new(300.0, 200.0); + let v = |i: u16, s: f32| (f32::from(i) * s + 0.123_456_7).fract(); + let rgb = |i: u16| Color::rgb(v(i, 0.137_911_3), v(i, 0.271_828_2), v(i, 0.314_159_3)); + let lab = |i: u16| Color::lab(v(i, 0.137_911_3) * 100.0, v(i, 0.271_828_2) * 200.0 - 100.0, v(i, 0.314_159_3) * 200.0 - 100.0); + let glaze = rgb(102); + let swatch = |name: String, c: Color, spot: bool| Swatch { name, paint: solid(c), global: true, spot }; + let mut named: Vec<(String, Color, bool)> = + vec![("Glaze".into(), glaze, true), ("Out".into(), Color::lab(104.567_89, -140.123_46, 131.987_65), true)]; + for i in 0..8u16 { + named.push((format!("Leaf {i}"), rgb(i + 110), false)); + named.push((format!("Fern {i}"), lab(i + 120), false)); + named.push((format!("Moss {i}"), lab(i + 130), true)); + } + let mut swatches: Vec<Swatch> = doc.swatches.iter().cloned().collect(); + swatches.extend(named.iter().map(|(name, c, spot)| swatch(name.clone(), *c, *spot))); + doc.swatches = swatches.into(); + let layer = doc.layers[0].id; + for i in 0..24u16 { + let x = f64::from(i % 8) * 30.0 + 10.0; + let y = f64::from(i / 8) * 30.0 + 10.0; + let n = square(&mut doc, Rect::new(x, y, x + 20.0, y + 20.0), rgb(i)); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let mut stops: Vec<GradientStop> = (0..5u16).map(|i| GradientStop::new(f32::from(i) / 5.0, rgb(i + 50))).collect(); + stops.push(GradientStop { swatch: Some("Glaze".into()), tint: 0.7, ..GradientStop::new(1.0, glaze.tinted(0.7)) }); + let mut fills = vec![gradient_paint(Gradient::new(GradientKind::Linear, stops), None, None)]; + fills.extend(named.iter().map(|(name, c, spot)| linked(name, *c, if *spot { 1.0 } else { 0.6 }))); + for (i, fill) in fills.into_iter().enumerate() { + let x = (i % 9) as f64 * 30.0 + 10.0; + let y = (i / 9) as f64 * 30.0 + 110.0; + let n = painted(&mut doc, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), vec![AppearanceItem::Fill(FillLayer::new(fill))]); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let first = program(&doc); + let again = String::from_utf8(export_ai_editing(&read_back(&doc), &opts()).unwrap().program).unwrap().replace("\r\n", "\n"); + same_lines(&again.lines().map(String::from).collect::<Vec<_>>(), &first.lines().map(String::from).collect::<Vec<_>>()); +} + #[test] fn the_data_is_zstandard_compressed_with_the_header_as_metadata() { let doc = sample(); @@ -5870,25 +5915,6 @@ fn outlines(doc: &Document) -> Vec<String> { out } -/// Fails with the lines where `got` and `want` differ in more than their numbers' last written -/// decimal (a color read back from four decimals gives its other model's values again from them). -fn near_lines(got: &str, want: &str) { - let near = |a: &str, b: &str| { - let (a, b): (Vec<&str>, Vec<&str>) = (a.split(' ').collect(), b.split(' ').collect()); - a.len() == b.len() - && a.iter().zip(&b).all(|(x, y)| match (x.trim_start_matches("%_").parse::<f64>(), y.trim_start_matches("%_").parse::<f64>()) { - (Ok(x), Ok(y)) => (x - y).abs() <= 2e-3, - _ => x == y, - }) - }; - let (g, w): (Vec<&str>, Vec<&str>) = (got.lines().collect(), want.lines().collect()); - let differ: Vec<String> = (0..g.len().max(w.len())) - .filter(|&i| !matches!((g.get(i), w.get(i)), (Some(a), Some(b)) if near(a, b))) - .map(|i| format!("line {i}:\n got {:?}\n want {:?}", g.get(i), w.get(i))) - .collect(); - assert!(differ.is_empty(), "{}", differ.join("\n")); -} - #[test] fn objects_with_live_effects_and_graphic_styles_are_read_back() { let doc = styles_document(); @@ -6210,25 +6236,7 @@ fn art_styles_write_the_same_bytes_again() { assert_eq!(private_data(&doc, &opts()).unwrap().0, private_data(&doc, &opts()).unwrap().0); // What is read back is written as it was. let again = export_ai_editing(&read_back(&doc), &opts()).unwrap().program; - near_lines(&rgb_without_cmyk(&shown(&again)), &rgb_without_cmyk(&shown(&first.program))); -} - -/// `program` with the CMYK equivalents of plain RGB colors blanked out: the first four numbers of -/// `c m y k r g b Xa` (or `XA`) and of an RGB gradient stop's `c m y k r g b 2 ... Bs`. Named and -/// spot RGB colors (`... (name) tint Xk`, `Xx` and spot stops) are not blanked; the document this -/// is used on has none. The Generic CMYK separation of a color at the edge of the press gamut can -/// land on one of two results that differ by up to 0.01 when the RGB changes in its last float -/// bits, as it does between the first save and the save of the reopened file, and the platform's -/// float math decides which. The RGB values stay compared. -fn rgb_without_cmyk(program: &str) -> String { - let blank = |line: &str| { - let (prefix, rest) = line.strip_prefix("%_").map_or(("", line), |r| ("%_", r)); - let t: Vec<&str> = rest.split(' ').collect(); - let color = t.len() == 8 && matches!(t[7], "Xa" | "XA"); - let stop = t.len() > 8 && t[7] == "2" && t.last() == Some(&"Bs"); - if color || stop { format!("{prefix}_ _ _ _ {}", t[4..].join(" ")) } else { line.to_string() } - }; - program.lines().map(blank).collect::<Vec<_>>().join("\n") + same_lines(&shown(&again).lines().map(String::from).collect::<Vec<_>>(), &shown(&first.program).lines().map(String::from).collect::<Vec<_>>()); } /// `n` on the first layer of `doc`. diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 35a47cc437..d5eeaeb08c 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -1344,7 +1344,8 @@ impl<'a> Writer<'a> { } /// A color that names no swatch: `g` (gray), `k` (CMYK), else `c m y k r g b Xa` (RGB, and - /// Lab as RGB), with its CMYK equivalent. + /// Lab as RGB), with its CMYK equivalent (an RGB color's from its values as written, a Lab + /// color's from its Lab values). fn plain_color(&mut self, color: Color, stroke: bool) { let s = &mut self.body; let op = |fill: &'static str, line: &'static str| if stroke { line } else { fill }; @@ -1359,7 +1360,8 @@ impl<'a> Writer<'a> { let _ = writeln!(s, "{}", op("k", "K")); } Color::Rgb { .. } | Color::Lab { .. } => { - let cmyk = self.cms.to_cmyk(&color, self.cms.settings().intent).map(unit); + let source = if matches!(color, Color::Rgb { .. }) { ai_ops::as_written(color) } else { color }; + let cmyk = self.cms.to_cmyk(&source, self.cms.settings().intent).map(unit); ps::push_nums(s, &cmyk.map(f64::from)); ps::push_nums(s, &color.to_rgb().map(|v| f64::from(unit(v)))); let _ = writeln!(s, "{}", op("Xa", "XA")); @@ -1368,10 +1370,12 @@ impl<'a> Writer<'a> { } /// A spot (`spot`) or global process color `name` of full color `full` at `tint`, written to - /// `s`: its CMYK equivalent, its RGB or Lab values, the name, how much lighter than the full - /// color, and for any but a CMYK spot color (`x`) the model (0 CMYK, 1 RGB, 2 Lab) before - /// `Xx` (spot) or `Xk` (global process). + /// `s`: its CMYK equivalent (from its RGB or Lab values as written), its RGB or Lab values + /// (clamped as [`ai_ops::as_written`] clamps them), the name, how much lighter than the full + /// color, and for any but a CMYK spot color (`x`) the model (0 CMYK, 1 RGB, 2 Lab) before `Xx` + /// (spot) or `Xk` (global process). fn named(&self, s: &mut String, name: &str, full: Color, tint: f32, spot: bool, stroke: bool) { + let full = ai_ops::as_written(full); let cmyk = self.cms.to_cmyk(&self.doc.linked_color(full, spot), self.cms.settings().intent).map(unit); ps::push_nums(s, &cmyk.map(f64::from)); let model = match full { @@ -1467,7 +1471,8 @@ impl<'a> Writer<'a> { true } - /// The spot color swatches, with their full colors' CMYK equivalents, for the header. + /// The spot color swatches, with their full colors' CMYK equivalents (as the palette writes + /// them), for the header. pub(super) fn spot_swatches(&self) -> Vec<(String, [f32; 4])> { let intent = self.cms.settings().intent; self.doc @@ -1475,7 +1480,7 @@ impl<'a> Writer<'a> { .filter(|sw| sw.spot) .filter_map(|sw| match sw.paint { Paint::Solid { color, swatch: None, .. } => { - Some((sw.name.clone(), self.cms.to_cmyk(&self.doc.linked_color(color, true), intent).map(unit))) + Some((sw.name.clone(), self.cms.to_cmyk(&self.doc.linked_color(ai_ops::as_written(color), true), intent).map(unit))) } _ => None, }) diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index f370167bcf..81857a9501 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -11,7 +11,7 @@ use vectorcraft_effects::stroke; use vectorcraft_geom::{Affine, BezPath, Rect, Shape, Vec2}; use super::{MAX_NEST, Writer, Xy}; -use crate::ai_ops::{average, unit}; +use crate::ai_ops::{self, average, unit}; use crate::ps; pub(in crate::ai_editing) const GRADIENTS: &str = @@ -371,7 +371,9 @@ impl Writer<'_> { /// A stop's color and its style: gray `lightness 0`, CMYK `c m y k 1`, RGB (and Lab, as RGB) /// `c m y k r g b 2` with its CMYK equivalent; a spot color its full color's CMYK equivalent, /// then its RGB values when it isn't CMYK, its name and how much lighter than the full color, - /// then `3` (CMYK), or `1 4` (RGB, and Lab as RGB). + /// then `3` (CMYK), or `1 4` (RGB, and Lab as RGB). An RGB color's CMYK equivalent is converted + /// from its values as written ([`ai_ops::as_written`]), a Lab color's from its Lab values, and a + /// spot color's is the one the palette writes for it. fn stop_color(&mut self, s: &mut String, stop: &GradientStop) { let doc = self.doc; let intent = self.cms.settings().intent; @@ -380,7 +382,7 @@ impl Writer<'_> { && let Paint::Solid { color: full, swatch: None, .. } = sw.paint { if sw.spot { - let cmyk = self.cms.to_cmyk(&doc.linked_color(full, true), intent).map(unit); + let cmyk = self.cms.to_cmyk(&doc.linked_color(ai_ops::as_written(full), true), intent).map(unit); ps::push_nums(s, &cmyk.map(f64::from)); let rgb = !matches!(full, Color::Cmyk { .. }); if rgb { @@ -406,7 +408,8 @@ impl Writer<'_> { s.push_str("1 "); } color @ (Color::Rgb { .. } | Color::Lab { .. }) => { - let cmyk = self.cms.to_cmyk(&color, intent).map(unit); + let source = if matches!(color, Color::Rgb { .. }) { ai_ops::as_written(color) } else { color }; + let cmyk = self.cms.to_cmyk(&source, intent).map(unit); ps::push_nums(s, &cmyk.map(f64::from)); ps::push_nums(s, &color.to_rgb().map(|v| f64::from(unit(v)))); s.push_str("2 "); diff --git a/crates/eps/src/ai_ops.rs b/crates/eps/src/ai_ops.rs index 7707f4bc45..14b19684f9 100644 --- a/crates/eps/src/ai_ops.rs +++ b/crates/eps/src/ai_ops.rs @@ -103,6 +103,18 @@ pub(crate) fn unit(v: f32) -> f32 { if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } } +/// `color` as a reader reads it back from the values written for it ([`ps::written`]): RGB values +/// clamped to 0..1, L to 0..100 and a and b to -128..127, each to four decimals; other colors +/// unchanged. +pub(crate) fn as_written(color: Color) -> Color { + let w = |v: f32| ps::written(f64::from(v)) as f32; + match color { + Color::Rgb { r, g, b } => Color::rgb(w(unit(r)), w(unit(g)), w(unit(b))), + Color::Lab { l, a, b } => Color::lab(w(l.clamp(0.0, 100.0)), w(a.clamp(-128.0, 127.0)), w(b.clamp(-128.0, 127.0))), + c => c, + } +} + /// The average colour of a gradient's stops (in RGB). pub(crate) fn average(g: &Gradient) -> Color { let n = g.stops.len().max(1) as f32; diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index cfb7af8186..5f1262b8b8 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -755,6 +755,28 @@ fn a_gradient_fill_and_its_stroke_are_one_object() { assert_eq!(n.appearance.stroke_paint().color(), Some(Color::rgb(1.0, 0.0, 0.0))); } +/// An RGB gradient stop's CMYK equivalent is the one of its RGB values as written (to four +/// decimals), so the reopened file writes the same stops. +#[test] +fn rgb_stops_are_written_again_unchanged() { + let mut doc = Document::new(100.0, 100.0); + let rgb = |i: u16| { + let v = |s: f32| (f32::from(i) * s + 0.123_456_7).fract(); + Color::rgb(v(0.137_911_3), v(0.271_828_2), v(0.314_159_3)) + }; + let stops = (0..12u16).map(|i| GradientStop::new(f32::from(i) / 11.0, rgb(i))).collect(); + let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let n = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 50.0)), paint, Paint::None); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let stops = |bytes: Vec<u8>| String::from_utf8(bytes).unwrap().lines().filter(|l| l.ends_with("%_Bs")).map(String::from).collect::<Vec<_>>(); + let first = write(&doc).bytes; + let again = write(&crate::import(&first).unwrap().document).bytes; + let (first, again) = (stops(first), stops(again)); + assert_eq!(first.len(), 12, "{first:?}"); + assert_eq!(again, first); +} + /// A gradient from white to #1b2a6b, the colors of #2687's circle, placed at `geom` (None: fitted /// to the object). fn white_to_navy(kind: GradientKind, geom: Option<vectorcraft_color::GradientGeom>) -> Paint { diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 19d3c59bb1..bbe7b0115b 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -633,7 +633,8 @@ impl<'a> Writer<'a> { // Grey is ink coverage; the format's grey is lightness. Color::Gray { k } => Ink::Gray(unit(1.0 - k)), Color::Cmyk { c, m, y, k } => Ink::Cmyk([c, m, y, k].map(unit)), - Color::Rgb { r, g, b } => Ink::Rgb(self.cms.to_cmyk(color, intent).map(unit), [r, g, b].map(unit)), + // The CMYK equivalent of the RGB values as written. + Color::Rgb { r, g, b } => Ink::Rgb(self.cms.to_cmyk(&ai_ops::as_written(*color), intent).map(unit), [r, g, b].map(unit)), Color::Lab { .. } => { let rgb = color.to_rgb().map(unit); Ink::Rgb(self.cms.to_cmyk(color, intent).map(unit), rgb) diff --git a/crates/eps/src/ps.rs b/crates/eps/src/ps.rs index 8fef6d75dd..88da08551e 100644 --- a/crates/eps/src/ps.rs +++ b/crates/eps/src/ps.rs @@ -36,6 +36,11 @@ pub(crate) fn push_num(s: &mut String, v: f64) { push_num_places(s, v, 4); } +/// The value [`num`]`(v)` stands for, as a reader parses it back. +pub(crate) fn written(v: f64) -> f64 { + num(v).parse().unwrap_or(0.0) +} + /// Append `v` to `s` with at most `places` decimals (up to 9), no trailing zeros (0 when not /// finite). pub(crate) fn push_num_places(s: &mut String, v: f64, places: u32) { From 89e52eaabc3c44b11ccc96ee17f815807662032b Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 08:50:46 -0800 Subject: [PATCH 52/92] MCP: a folder preference is checked as trimmed, and only a %XX escape is decoded Under automation read roots, `ui.inspect` kept a folder preference whose stored value was readable even when the trimmed path the app uses was not: ` /outside/fonts` reads as a name in the working folder, while the app uses `/outside/fonts`. A folder preference now stays only when both forms are readable. A reply string is decoded for path matching only when it holds a `%XX` escape, so a string such as `width="100%"` is no longer decoded and thrown away. The library list line in the progress doc now states when a hidden library's `loaded/<file name>` id moves. --- crates/ui-egui/src/control.rs | 15 +++++++++++---- crates/ui-egui/src/control/tests.rs | 15 +++++++++++++++ crates/ui-egui/src/tests/automation.rs | 25 +++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index f8022a3593..f568f5446c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -648,7 +648,7 @@ fn occurrences(text: &str, needle: &str, fold: bool) -> Vec<usize> { /// in letter case keep their own ids, and then the ones written in another case. `None` when nothing /// changed. fn replace_text(s: &str, r: &Rewrite) -> Option<String> { - let decoded = if s.contains('%') { decoded_with_spans(s).filter(|(decoded, _)| decoded != s) } else { None }; + let decoded = if has_percent_escape(s) { decoded_with_spans(s).filter(|(decoded, _)| decoded != s) } else { None }; let mut found = Found::default(); for group in &r.pairs { for fold in [false, true].into_iter().take(if r.fold { 2 } else { 1 }) { @@ -774,6 +774,12 @@ fn decoded_with_spans(text: &str) -> Option<(String, Vec<(usize, usize)>)> { Some((decoded, decoded_spans)) } +/// Does `text` hold a `%XX` escape, one that [`decoded_with_spans`] decodes? Only such an escape +/// changes the decoded text. +fn has_percent_escape(text: &str) -> bool { + text.as_bytes().windows(3).any(|w| w[0] == b'%' && w[1].is_ascii_hexdigit() && w[2].is_ascii_hexdigit()) +} + /// Is `from`, a relative path (one that does not start with a separator, a drive or `loaded/`), at /// `text[at..]` the end of a longer path: after any `.` and `..` parts just before it, is it preceded by /// a letter, a digit, `_`, `-`, `.`, `~` or a separator? `/work/data/icon.svg` and @@ -1060,13 +1066,14 @@ fn leave_out_unreadable_paths(ui: &mut serde_json::Map<String, Value>) { } } -/// Takes out of `prefs` the folder preferences ([`PREF_FOLDERS`]) the request may not read. An -/// empty value (no folder) and a scratch disk choice ([`SCRATCH_CHOICES`]) stay. +/// Takes out of `prefs` the folder preferences ([`PREF_FOLDERS`]) the request may not read, as written +/// or trimmed as the app uses it. An empty value (no folder) and a scratch disk choice +/// ([`SCRATCH_CHOICES`]) stay. fn leave_out_unreadable_folders(prefs: &mut serde_json::Map<String, Value>) { for key in PREF_FOLDERS { let Some(written) = prefs.get(key).and_then(Value::as_str) else { continue }; let path = written.trim(); - if path.is_empty() || (key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path)) || readable(written) { + if path.is_empty() || (key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path)) || (readable(written) && readable(path)) { continue; } prefs.remove(key); diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index babc710067..6ae364e172 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -131,6 +131,21 @@ fn a_reply_hides_a_path_in_any_case_and_escaped() { assert_eq!(reply["error"], "c.svg, c.svg, c.svg", "{reply}"); } +/// Only a string with a `%XX` escape is decoded before it is searched: a `%` that begins no escape +/// leaves the text as it is, and the reply is the same either way. +#[test] +fn only_a_percent_escape_is_decoded() { + for (text, escaped) in [("width=\"100%\"", false), ("50% off", false), ("%zz%4", false), ("%", false), ("a%2Fb", true), ("%fF", true)] { + assert_eq!(super::has_percent_escape(text), escaped, "{text}"); + let decoded = super::decoded_with_spans(text).map(|(decoded, _)| decoded); + assert_eq!(decoded.as_deref() != Some(text), escaped, "{text}: an escape alone changes the decoded text"); + } + let hidden = hiding(&[("loaded//libs/b.svg", "loaded/b.svg")], &[]); + let mut reply = json!({"error": "100% of /libs/b.svg and file:///libs%2Fb.svg"}); + super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&hidden), hiding: &hidden, fold: false }); + assert_eq!(reply["error"], "100% of b.svg and b.svg", "{reply}"); +} + /// A path a failed request sent takes the place of a hidden library's id for the same path, written /// with the other separator, and in another letter case when matching in any case (as on Windows). #[test] diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 0ed949edf5..43072d4a6e 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -499,6 +499,31 @@ fn ui_inspect_checks_a_folder_preference_as_written() { let _ = std::fs::remove_dir_all(base); } +/// `ui.inspect` checks a folder preference as the app uses it, trimmed, as well: with the working +/// folder as the read root, a path outside it with a space before it reads as a name in the working +/// folder, and is still left out, because the app trims it and uses the path outside. +#[test] +fn ui_inspect_checks_a_folder_preference_as_trimmed() { + let (_, _, base) = confined("inspect-folder-trimmed"); + let cwd = std::env::current_dir().unwrap(); + let roots = AutomationRoots::new(&[&cwd], &[&base.join("out")]).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Services::default()).with_control(rx).with_automation_roots(roots); + let outside = s(base.join("outside")); + // Without its drive, the path starts with a separator: trimmed, it is absolute on any platform. + let rooted = match outside.as_bytes() { + [drive, b':', ..] if drive.is_ascii_alphabetic() => &outside[2..], + _ => outside.as_str(), + }; + let spaced = format!(" {rooted}"); + app.ui.engine_prefs = json!({"fontsFolder": spaced, "pluginsFolder": ""}); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let prefs = r["result"]["ui"]["engine_prefs"].as_object().unwrap(); + assert_eq!(prefs.get("pluginsFolder").and_then(Value::as_str), Some(""), "{r}"); + assert!(!prefs.contains_key("fontsFolder"), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// `ui.inspect` hides a hidden library's id and path in every string it holds: status text, the /// command palette query and a script's report show the id and file name shown, and the path appears /// nowhere in the reply. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0c567096a4..75141ea9a6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -769,7 +769,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). - Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`). - Linked-file and font-file searches under `--automation-read-root`: `links.findFiles` and `text.findFontFiles` replies, also later polls and stops of a search another client started, leave out the files found, a link's saved path and the folder searched outside the caller's read roots, and a failed search gives its reason without the path; `image.info` gives `link: null` for a linked file outside them; `text.addFontFiles` gives `folder: null` and the copies' file names when the Fonts folder is outside them. `crates/engine/src/tests/linkfiles.rs`, `crates/engine/src/tests/fontfiles.rs`, `crates/engine/tests/font_folder.rs`. - - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: every library stays listed, the paths of library files and the user library folder outside the read roots are `null`, a library loaded from outside them is listed and found as `loaded/<file name>` and keeps that id for the session, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. + - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: every library stays listed, the paths of library files and the user library folder outside the read roots are `null`, a library loaded from outside them is listed and found as `loaded/<file name>` and keeps that id for the session unless a library loaded later without a file shows that id itself, which moves it to the next free `loaded/<file name> (N)`, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. - Preferences commands under `--automation-read-root`: `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show folder preferences outside the read roots as `null` and leave out an export preset's folder outside them, and the preferences are not changed. `crates/engine/src/tests/prefs_roots.rs`. - Plug-in, recovery and script report replies under `--automation-read-root`: `plugin.list`, `plugin.info` and `plugin.safeMode` name the plug-in folder, failed files, plug-in sources and disabled plug-ins' files outside the read roots by file name or `null`, `file.recovery.*` leave out a document's original file and the recovery folder outside them, and `ui.inspect`'s `scriptReport` names files a run wrote outside them by file name. `crates/engine/src/tests/plugins.rs` (`plugin_list_leaves_out_paths_outside_the_read_root`), `crates/engine/src/tests/recovery.rs` (`recovery_replies_leave_out_paths_outside_the_read_root`), `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_script_report_leaves_out_files_outside_the_read_root`). - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. From 91e1412d779a15a59b01b46f9125221913ae659b Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 07:50:46 -0800 Subject: [PATCH 53/92] Effects: Roughen at size 0 keeps the outline With Size 0, Roughen moved no point but still replaced each segment with its Detail's points joined by straight lines (Corner) or by a smoothed curve through them (Smooth), so a curved path lost its shape. At Size 0 each segment is now split at the Detail spacing into pieces of equal length on the segment itself, with `pathtools::insert_at`: a straight segment gets corner points without handles, and a curve gets smooth points whose handles follow it. The path keeps its outline with Smooth or Corner points alike. The params text of `distort.roughen` describes size 0. `insert_at` now counts the anchors added before each returned mark in one pass. It counted them again for every split segment, which took quadratic time on a path with many split segments. Its results are unchanged. Existing documents with a Roughen of Size 0 on a curved path, or with Smooth points, draw differently. This is VectorCraft's own rule. What Roughen does is described in https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html --- crates/effects/src/catalog.rs | 2 +- crates/effects/src/distort.rs | 23 ++++- crates/effects/src/tests.rs | 90 +++++++++++++++++++ crates/eps/src/import/ai/style/tests.rs | 3 +- .../pathops/src/addons/pathtools/crossings.rs | 14 ++- docs/target-app-parity.md | 1 + 6 files changed, 128 insertions(+), 5 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 84991392b2..d22a4b06b3 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -258,7 +258,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> { "distort.roughen", "Roughen…", DT, - "{size: 0..100 (5), relative: bool (true: % of size; false: pt), detail: per inch 0..100 (10), points: \"smooth\"|\"corner\", seed: int (0)}", + "{size: 0..100 (5; 0 keeps the outline and adds its points along it), relative: bool (true: % of size; false: pt), detail: per inch 0..100 (10), points: \"smooth\"|\"corner\", seed: int (0)}", json!({"size": 5.0, "relative": true, "detail": 10.0, "points": "smooth", "seed": 0}), ), g( diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index c070457027..a5bc941b16 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -3,6 +3,7 @@ use serde_json::Value; use vectorcraft_geom::kurbo::ParamCurveArclen; use vectorcraft_geom::{Affine, Anchor, ParamCurve, PathData, Point, Rect, SubPath, Vec2}; +use vectorcraft_pathops::addons::pathtools::insert_at; use crate::util::{catmull_rom, fit_piece, flag, map_nonlinear_within, mean_size, noise, normal_at, num, pieces, seed, seg_cubic, smooth_points}; @@ -79,7 +80,8 @@ const MAX_ROUGHEN_POINTS: usize = 2000; /// Roughen: resample at `detail` points per inch and jitter each point by up to `size`. Where /// that would make more than `max` anchors, the points are spaced further apart until it doesn't -/// (stacked Roughens multiply their points, #3578). +/// (stacked Roughens multiply their points, #3578). At size 0 no point moves: each segment is +/// split into its pieces, evenly along its length, and the outline stays as it is. pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData { let size = num(p, "size", 5.0).clamp(0.0, 1e4); let amp = if flag(p, "relative", true) { size / 100.0 * mean_size(b) } else { size }; @@ -89,6 +91,23 @@ pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData { let lens: Vec<f64> = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i).arclen(0.1))).collect(); let spacing = fit_piece(&lens, spacing, MAX_ROUGHEN_POINTS, max.saturating_sub(path.anchor_count())); let mut lens = lens.iter(); + if amp == 0.0 { + // Each segment's pieces, cut at equal lengths as `pathtools::add_points` cuts them. + let mut subs = Vec::with_capacity(path.subpaths.len()); + for sp in &path.subpaths { + let mut places = vec![]; + for (i, &coarse) in (0..sp.segment_count()).zip(&mut lens) { + let k = pieces(coarse, spacing, MAX_ROUGHEN_POINTS); + let c = sp.segment(i); + let len = c.arclen(ROUGHEN_ARCLEN_ACCURACY); + if len > 1e-9 && len.is_finite() { + places.extend((1..k).map(|j| (i, c.inv_arclen(len * j as f64 / k as f64, ROUGHEN_ARCLEN_ACCURACY)))); + } + } + subs.push(insert_at(sp, &places).0); + } + return PathData::new(subs); + } let smooth = smooth_points(p); let seed = seed(p); let mut subs = vec![]; @@ -118,6 +137,8 @@ pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData { PathData::new(subs) } +const ROUGHEN_ARCLEN_ACCURACY: f64 = 1e-4; + /// The move and the number of copies of a Transform effect with params `p` that only moves what /// it applies to, as [`transform_map`] reads them; `None` when it also scales, rotates or reflects, /// or moves by a random share, or moves pattern paints along (Transform Patterns), or scales diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index e63b38a462..5246a639e8 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -1251,6 +1251,96 @@ fn roughen_smooth_makes_no_cusps() { } } +#[test] +fn roughen_at_size_0_keeps_the_outline_and_spaces_its_points_evenly() { + use vectorcraft_geom::AnchorKind; + use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest}; + let (a, b) = (Point::new(0.0, 0.0), Point::new(300.0, 0.0)); + let curve = PathData::single(SubPath::new( + vec![ + vectorcraft_geom::Anchor::with_handles(a, a, Point::new(10.0, 40.0)), + vectorcraft_geom::Anchor::with_handles(b, Point::new(100.0, 200.0), b), + ], + false, + )); + let ellipse = shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 50.0)); + // Two subpaths with lines and curves: a closed one whose last segment is a line, and an open + // one that ends with a line. + let (c, d, e, f) = (Point::new(100.0, 100.0), Point::new(200.0, 0.0), Point::new(300.0, 0.0), Point::new(300.0, 100.0)); + let mixed = PathData::new(vec![ + SubPath::new( + vec![ + vectorcraft_geom::Anchor::corner(a), + vectorcraft_geom::Anchor::with_handles(Point::new(100.0, 0.0), Point::new(100.0, 0.0), Point::new(160.0, 20.0)), + vectorcraft_geom::Anchor::with_handles(c, Point::new(160.0, 80.0), c), + ], + true, + ), + SubPath::new( + vec![ + vectorcraft_geom::Anchor::with_handles(d, d, Point::new(220.0, 60.0)), + vectorcraft_geom::Anchor::with_handles(e, Point::new(280.0, 60.0), e), + vectorcraft_geom::Anchor::corner(f), + ], + false, + ), + ]); + for path in [square(), ellipse, curve, mixed] { + let all: Vec<_> = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i))).collect(); + let off = |q: Point| all.iter().map(|c| c.nearest(q, 1e-9).distance_sq).fold(f64::INFINITY, f64::min).sqrt(); + for params in + [json!({"size": 0}), json!({"size": 0, "points": "corner"}), json!({"size": 0, "relative": false, "points": "corner", "seed": 7})] + { + let out = run("distort.roughen", params.clone(), &path); + assert!(out.anchor_count() > path.anchor_count(), "{params}: the points are added"); + assert_eq!(out.subpaths.len(), path.subpaths.len(), "{params}"); + for (sp, got) in path.subpaths.iter().zip(&out.subpaths) { + assert_eq!(got.closed, sp.closed); + // Walk the result one original segment at a time: its pieces end on the segment's end anchor. + let mut at = 0; + for i in 0..sp.segment_count() { + let (orig, end) = (sp.segment(i), sp.anchors[(i + 1) % sp.anchors.len()]); + let start = got.anchors[at]; + let was = sp.anchors[i]; + assert_eq!((start.p, start.kind, start.has_in(), start.has_out()), (was.p, was.kind, was.has_in(), was.has_out()), "{params}"); + let mut pieces = vec![]; + loop { + let piece = got.segment(at); + for j in 0..=8 { + let q = piece.eval(j as f64 / 8.0); + assert!(off(q) < 1e-6, "{params}: {q:?} is {} off the outline", off(q)); + } + pieces.push(piece.arclen(1e-6)); + at += 1; + if piece.p3.distance(end.p) < 1e-9 { + break; + } + // A point the effect added: a corner without handles on a line, a smooth + // point whose handles follow the curve. + let added = got.anchors[at]; + if sp.segment_is_line(i) { + assert_eq!((added.kind, added.has_in(), added.has_out()), (AnchorKind::Corner, false, false), "{params}"); + } else { + assert_eq!(added.kind, AnchorKind::Smooth, "{params}"); + let (hi, ho) = (added.h_in - added.p, added.h_out - added.p); + assert!(hi.cross(ho).abs() < 1e-6 * hi.hypot() * ho.hypot() && hi.dot(ho) < 0.0, "{params}: {added:?}"); + } + } + assert!(pieces.len() > 1, "{params}: segment {i} is split"); + assert!(close(pieces.iter().sum::<f64>(), orig.arclen(1e-6), 1e-3), "{params}: {pieces:?}"); + let (lo, hi) = pieces.iter().fold((f64::INFINITY, 0.0f64), |(lo, hi), &l| (lo.min(l), hi.max(l))); + assert!(hi - lo < 1e-3, "{params}: segment {i}'s pieces from {lo} to {hi}"); + } + assert_eq!(at, got.segment_count(), "{params}"); + if !sp.closed { + let (last, was) = (got.anchors[at], sp.anchors[sp.anchors.len() - 1]); + assert_eq!((last.p, last.kind, last.has_in(), last.has_out()), (was.p, was.kind, was.has_in(), was.has_out()), "{params}"); + } + } + } + } +} + #[test] fn zigzag_ridge_count_and_amplitude() { let out = run("distort.zigZag", json!({"ridges": 4, "points": "corner", "size": 10}), &square()); diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index 47086ca171..b4481b60cc 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -1027,8 +1027,7 @@ fn affine_corners_are_a_parallelogram_however_far_they_reach() { #[test] fn roughen_keeps_the_drawn_look() { - // Its outline is random and the file holds no seed; at a size of 0 the effect still redraws the - // path's segments. + // Its outline is random and the file holds no seed. assert_eq!(read_effect("Adobe Roughen", "30 /Real (size) ,\n"), None); assert_eq!(read_effect("Adobe Roughen", ""), None); } diff --git a/crates/pathops/src/addons/pathtools/crossings.rs b/crates/pathops/src/addons/pathtools/crossings.rs index e1d96ed0fd..d216a2f4b3 100644 --- a/crates/pathops/src/addons/pathtools/crossings.rs +++ b/crates/pathops/src/addons/pathtools/crossings.rs @@ -182,7 +182,19 @@ pub fn insert_at(sp: &SubPath, places: &[(usize, f64)]) -> (SubPath, BTreeSet<us out.anchors.push(a); out.anchors.extend(added.remove(&i).unwrap_or_default()); } - let added_before = |j: usize| inner.range(..j).map(|(_, v)| v.len()).sum::<usize>(); + // How many anchors were added before each anchor, counted in one pass for all of them. + let mut before = vec![0usize; n + 1]; + for (&seg, ts) in &inner { + if let Some(b) = before.get_mut(seg + 1) { + *b += ts.len(); + } + } + let mut sum = 0; + for b in &mut before { + sum += *b; + *b = sum; + } + let added_before = |j: usize| before.get(j).copied().unwrap_or(0); let mut marks: BTreeSet<usize> = at_anchor.into_iter().map(|j| j + added_before(j)).collect(); for (&seg, ts) in &inner { let base = seg + added_before(seg); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b172aefd7e..dcfe0c00e3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -598,6 +598,7 @@ since 2026-10-01. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. - **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged.- **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (<https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests. +- **Roughen at Size 0 keeps the outline:** with Size 0 (relative or absolute), Effect › Distort & Transform › Roughen… moves no point: each segment is split at the Detail spacing (fewer points past the anchor limit) into pieces of equal length on the segment itself, so the path keeps its outline, with Smooth or Corner points alike. A straight segment gets corner points without handles, and a curve gets smooth points whose handles follow it. Before, the points were joined by straight lines (Corner) or a smoothed curve through them (Smooth), which changed curved outlines. Existing documents with a Roughen of Size 0 on a curved path, or with Smooth points, draw differently. This is VectorCraft's own rule; what Roughen does is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `tests::roughen_at_size_0_keeps_the_outline_and_spaces_its_points_evenly`). Scores and hours unchanged. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged. - **Copy and Cut of anchors picked with a lent Direct Selection (#6123):** with the Pen or the Curvature tool, Cmd/Ctrl held lends Direct Selection for the gesture; the anchors and segments it picks are what Edit › Copy and Cut take once the key is let go, as with Direct Selection chosen, while the last anchor the Pen leaves selected as it draws still makes Copy take the whole path (#2122). A Cmd gesture that leaves the selection as it was (a Shift marquee over nothing) picks nothing. Regression test: `tests::draw2::pen_cmd_selected_anchors_are_what_copy_and_cut_take`. From 70b26c12fb3a2343aa88e7ff5de3edda6aecc53d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 09:02:40 -0800 Subject: [PATCH 54/92] Updates: take craft-update from craft-libs main at e98299546 craft-update and craft-vectorcraft-format move from storytold/craft-libs at 6e03f65bf to e98299546, craft-libs main with 47b6bbadf. The startup check's thread now reads whether the copy installs itself, so a slow disk no longer stalls a frame when the startup prompt is built. Both crates stay on one craft-libs revision; the other craft-libs pins are unchanged. Cargo.lock changes only the two crates' source. --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f9c3dacaa3..a4eac55700 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1081,7 +1081,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" +source = "git+https://github.com/storytold/craft-libs?rev=e98299546f5c5c3c22388d16507f8b5e61a5f280#e98299546f5c5c3c22388d16507f8b5e61a5f280" dependencies = [ "chrono", "dunce", @@ -1108,7 +1108,7 @@ dependencies = [ [[package]] name = "craft-vectorcraft-format" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" +source = "git+https://github.com/storytold/craft-libs?rev=e98299546f5c5c3c22388d16507f8b5e61a5f280#e98299546f5c5c3c22388d16507f8b5e61a5f280" dependencies = [ "flate2", "serde", diff --git a/Cargo.toml b/Cargo.toml index 29f43866c2..ec55a218f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,11 +21,11 @@ craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "9be9a637 # desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.) craft-window = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", features = ["egui"] } # The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON. -craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" } +craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "e98299546f5c5c3c22388d16507f8b5e61a5f280" } # craft-update: app-specific pretend versions apply to startup and manual checks. # Native Affinity documents (craft-libs#914). craft-affinity = { git = "https://github.com/storytold/craft-libs", rev = "228f6d0c50f1fdee4c5124603602993fc79d4c9d" } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e98299546f5c5c3c22388d16507f8b5e61a5f280", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } vectorcraft-color = { path = "crates/color" } From c8a6b4d9c3c458d5e28708a18293ec98f019fa7b Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 09:12:40 -0800 Subject: [PATCH 55/92] MCP: a base64 thumbnail in a reply is sent as it is Under automation read roots, a reply's base64 `thumbnail` (a library item's PNG in `library.get`, a page's PNG in `document.pdfInfo`) was searched for hidden library paths like any other text, so a short path that happened to occur in the encoded bytes was rewritten and the image broke. `thumbnail` now joins `dataBase64`, `pngBase64` and `thumbnailBase64` as encoded data that is left unchanged; text under it that is not base64 is still searched. --- crates/ui-egui/src/control.rs | 5 +++-- crates/ui-egui/src/control/tests.rs | 10 +++++++--- docs/mcp.md | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index f568f5446c..002a739d23 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -572,8 +572,9 @@ struct Rewrite<'a> { fold: bool, } -/// The keys of encoded file data in a reply. -const BASE64_FIELDS: [&str; 3] = ["dataBase64", "pngBase64", "thumbnailBase64"]; +/// The keys of encoded file data in a reply. `thumbnail` holds the PNG of a library item in +/// `library.get` and of a page in `document.pdfInfo`. +const BASE64_FIELDS: [&str; 4] = ["dataBase64", "pngBase64", "thumbnailBase64", "thumbnail"]; /// Is `s` written in the base64 alphabet, padded to a multiple of 4? fn is_base64(s: &str) -> bool { diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 6ae364e172..5797b0ddb9 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -40,14 +40,16 @@ fn hide_under_a_root(tag: &str, reply: &mut Value, hidden: &super::Hiding) { let _ = std::fs::remove_dir_all(root); } -/// Encoded file data in a reply, a base64 string under `dataBase64`, `pngBase64` or -/// `thumbnailBase64`, stays as it is when it holds a hidden library's path. Text that is not base64 -/// under such a key, an object or a list under it, and any other key ending in `Base64` are searched. +/// Encoded file data in a reply, a base64 string under `dataBase64`, `pngBase64`, `thumbnailBase64` +/// or `thumbnail` (a library item's or a PDF page's PNG), stays as it is when it holds a hidden +/// library's path. Text that is not base64 under such a key, an object or a list under it, and any +/// other key ending in `Base64` are searched. #[test] fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() { let hidden = hiding(&[("loaded/assets/icon", "loaded/icon")], &[]); let data = "QUJ+assets/iconREVGxQQ=="; let mut reply = json!({"ok": true, "result": { + "graphics": [{"name": "a", "thumbnail": data}, {"name": "b", "thumbnail": "assets/icon.png"}], "files": [{"name": "a.png", "dataBase64": data, "pngBase64": "assets/icon.png", "thumbnailBase64": data}], "status": "from assets/icon", "dataBase64": {"note": "from assets/icon"}, @@ -58,6 +60,8 @@ fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() { let files = &reply["result"]["files"][0]; assert_eq!((&files["dataBase64"], &files["thumbnailBase64"]), (&json!(data), &json!(data)), "{reply}"); assert_eq!(files["pngBase64"], "icon.png", "{reply}"); + let graphics = &reply["result"]["graphics"]; + assert_eq!((&graphics[0]["thumbnail"], &graphics[1]["thumbnail"]), (&json!(data), &json!("icon.png")), "{reply}"); assert_eq!(reply["result"]["status"], "from icon", "{reply}"); assert_eq!(reply["result"]["dataBase64"]["note"], "from icon", "{reply}"); assert_eq!(reply["result"]["pngBase64"][0], "from icon", "{reply}"); diff --git a/docs/mcp.md b/docs/mcp.md index faae559e61..ab3a372be9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -187,7 +187,7 @@ the suite) keep the agent to the folders you give it: matched before the paths the request sent, so a sent path written over part of a hidden library's path does not keep that path in the reply. A relative path is not replaced where it ends a longer path (`/work/data/icon.svg` keeps `data/icon.svg`), and the absolute path it reads as from the app's working folder is replaced too. Encoded file data, a base64 string under `dataBase64`, - `pngBase64` or `thumbnailBase64`, is sent as it is. + `pngBase64`, `thumbnailBase64` or `thumbnail` (`library.get`, `document.pdfInfo`), is sent as it is. - `swatch.library.list`, `brush.libraries`, `symbol.libraries` and `graphicStyle.libraries` list every library but leave out the paths outside the read roots: a library's `path` and the user library folder in `userFolder` are `null` there. A library loaded from a file outside the roots is listed as `loaded/<file name>`, numbered From 714d72971cdb3508ab40beba55952fc630cac06c Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 09:16:50 -0800 Subject: [PATCH 56/92] 3D and Materials: the 3D area row lists Expand as Wireframes and its tests --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d4c411e30a..18c72f7d60 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -230,7 +230,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, and Map Markers are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility), Revolve (Classic) with Angle, Cap, Offset and the Plastic, Diffuse, No Shading and Wireframe surfaces; materials, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); Revolve (Classic) is covered by the `revolve::classic` tests and `dialogs::revolve::tests::classic_revolve_shows_its_options_and_applies_as_one_step` | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility), Revolve (Classic) with Angle, Cap, Offset and the Plastic, Diffuse, No Shading and Wireframe surfaces, the Quick Actions › Expand as Wireframes command (#6354); materials, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); Revolve (Classic) is covered by the `revolve::classic` tests and `dialogs::revolve::tests::classic_revolve_shows_its_options_and_applies_as_one_step`; Expand as Wireframes is covered by `crates/effects/src/solids/wireframe/tests.rs` and `crates/engine/src/cmd/wireframe/tests.rs` (score, gaps and hours unchanged) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 55% | — | — | estimated (raised from 52%: the Effect Gallery's filter thumbnails and in-dialog preview, an original landscape drawing passed through each filter, [Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html), `dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`; the gallery itself shipped in #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; the score stays under 100% because the filters are original approximations; depth estimated | From 2d66758ce480eec4b667e52489bf9171ef9bfd68 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 09:19:15 -0800 Subject: [PATCH 57/92] MCP: document.json checks brush art and relative link paths against the read roots Under --automation-read-root, document.json names a linked image or placed file in a stored brush's art outside the read roots by its file name alone, as it does in the layers. A stored brush entry that does not read as a brush is left out of the reply, and a stored brush library that is not a list becomes an empty one. A link whose file is inside the read roots loses its stored relative path when that path leads outside them, from the folder of the file the document was read from or from the document's own folder. --- crates/engine/src/cmd/edit.rs | 4 +- crates/engine/src/cmd/links.rs | 42 ++++++++++++++++++--- crates/engine/src/tests/placed_document.rs | 44 ++++++++++++++++++++++ docs/mcp.md | 8 ++-- docs/target-app-parity.md | 2 +- 5 files changed, 88 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 8b1e147625..8c86bbd777 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -50,9 +50,9 @@ pub fn specs() -> Vec<CommandSpec> { has_doc, doc_compare ), - cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model (with automation read roots, a linked or placed file outside them is named by its file name alone, without its relative path, size or date, and so is a data set's image value outside them; a folder outside them is left out of export_settings)", has_doc, |s, _| { + cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model (with automation read roots, a linked or placed file outside them is named by its file name alone, without its relative path, size or date, in brush art too, and so is a data set's image value outside them; a relative path that leads outside them is left out; a stored brush entry that does not read as a brush is left out; a folder outside them is left out of export_settings)", has_doc, |s, _| { let st = s.doc()?; - let d = super::links::with_hidden_paths_named(&st.doc, super::links::LinkBase::of(st).doc); + let d = super::links::with_hidden_paths_named(&st.doc, super::links::LinkBase::of(st)); Ok(serde_json::to_value(&*d).unwrap_or(Value::Null)) }), cmd!( diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index effa917d8e..0f2d6ea1e7 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -1202,27 +1202,57 @@ pub(crate) fn with_found_paths(d: &mut Document, base: LinkBase) { /// allow names its file alone, without its relative path or the size and date kept of its file, /// and each data set's image value it does not allow names its file alone too. The folder Export /// for Screens last wrote to is left out of the export settings when it is outside the read roots. -/// Links in the layers, symbols, graph designs, patterns, the pattern being edited as it was -/// before and opacity masks are all checked. -pub(crate) fn with_hidden_paths_named<'a>(d: &'a Document, doc_path: Option<&str>) -> Cow<'a, Document> { +/// A link whose path is allowed but whose relative path leads outside the read roots loses its +/// relative path. Links in the layers, symbols, graph designs, patterns, the pattern being edited +/// as it was before, opacity masks and the art of the stored brushes are all checked. A stored +/// brush entry that does not read as a brush is left out of the copy, since its links cannot be +/// checked, and a stored brush library that is not a list becomes an empty one. +pub(crate) fn with_hidden_paths_named<'a>(d: &'a Document, base: LinkBase) -> Cow<'a, Document> { if crate::file_access::current().is_none() { return Cow::Borrowed(d); } + let doc_path = base.doc; + // A stored relative path is followed from the folder of the file the document was read from, + // and a link is also looked for from the document's own folder: it is shown only when both + // places it names are inside the read roots. + let relative_hidden = |r: &str| [base.relative, base.doc].into_iter().any(|from| !path_shown(r, from)); let mut d = d.clone(); // The pattern being edited as it was before is checked as one more pattern, then put back. let original = d.pattern_edit.as_mut().and_then(|e| e.original.take()); let edited = original.is_some(); d.patterns.extend(original); + // The art of each stored brush is checked as more layers, then put back. + let stored = d.unknown.get(vectorcraft_brush::DOC_KEY).map(|v| v.as_array().cloned().unwrap_or_default()); + let mut brushes: Vec<vectorcraft_brush::Brush> = + stored.iter().flatten().filter_map(|raw| vectorcraft_brush::Brush::from_json(raw.clone()).ok()).collect(); + let layers = d.layers.len(); + d.layers.extend(brushes.iter().flat_map(|b| b.art()).map(|n| Arc::new(n.clone()))); d.update_links_where( - |l| !path_shown(&l.path, doc_path), + |l| !path_shown(&l.path, base.doc) || l.relative.as_deref().is_some_and(relative_hidden), |l| { - let name = l.name().to_string(); - *l = LinkInfo { path: name, relative: None, modified: None, size: None, ..l.clone() }; + if path_shown(&l.path, base.doc) { + l.relative = None; + } else { + let name = l.name().to_string(); + *l = LinkInfo { path: name, relative: None, modified: None, size: None, ..l.clone() }; + } }, ); + let mut checked = d.layers.split_off(layers).into_iter(); if edited && let (Some(def), Some(e)) = (d.patterns.pop(), d.pattern_edit.as_mut()) { e.original = Some(def); } + if stored.is_some() { + // Each brush is written as it reads, so fields it does not read are left out too. + let mut kept = vec![]; + for b in &mut brushes { + for (slot, art) in b.art_mut().into_iter().zip(&mut checked) { + *slot = Arc::unwrap_or_clone(art); + } + kept.extend(serde_json::to_value(&*b).ok()); + } + d.unknown.insert(vectorcraft_brush::DOC_KEY.into(), Value::Array(kept)); + } if d.export_settings.get("folder").and_then(Value::as_str).is_some_and(|f| crate::file_access::check_read(f).is_err()) { d.export_settings.remove("folder"); } diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index 3ec0310430..1a6ee921f2 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -1136,6 +1136,50 @@ fn document_replies_leave_out_paths_outside_the_read_roots() { assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["path"], json!(poster)); } +/// `document.json` for a confined caller: a linked image in a brush's art outside the read roots +/// is named by its file name, a stored brush entry that does not read as a brush is left out, and a +/// link inside them whose stored relative path leads outside them loses that relative path. +#[test] +fn document_json_checks_brush_art_and_relative_paths_against_the_read_roots() { + let away = Folder::new("doc-brr-away"); + let home = Folder::new("doc-brr-home"); + let pic = away.file("photo.png"); + write(&pic, &crate::tests_links::png(60, 30, BLUE)); + let near_pic = home.file("near.png"); + write(&near_pic, &crate::tests_links::png(20, 10, RED)); + let mut s = session(); + let r = s.execute("file.place", &json!({"path": pic, "at": [50, 50]})).unwrap(); + assert_eq!(r["linked"], true, "{r}"); + let image = r["ids"][0].as_u64().unwrap(); + let r = s.execute("file.place", &json!({"path": near_pic, "at": [150, 50]})).unwrap(); + assert_eq!(r["linked"], true, "{r}"); + s.execute("brush.new", &json!({"type": "art", "name": "Photo Band", "ids": [image]})).unwrap(); + save(&mut s, &home.file("poster.vectorcraft")); + { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let lib = d.unknown.get_mut(vectorcraft_brush::DOC_KEY).and_then(Value::as_array_mut).unwrap(); + lib.push(json!({"name": "Broken Band", "type": "unknown kind", "note": pic})); + d.update_links(|l| { + if l.name() == "near.png" { + l.relative = Some("../doc-brr-away/near.png".into()); + } + }); + } + let unconfined = s.execute("document.json", &json!({})).unwrap().to_string(); + for part in [json!(pic).to_string(), "Broken Band".into(), "../doc-brr-away/near.png".into()] { + assert!(unconfined.contains(&part), "{part}: {unconfined}"); + } + let roots = crate::file_access::AutomationRoots::new(&[&home.0], &[]).unwrap().unwrap(); + let model = crate::file_access::confine(Some(&roots), || s.execute("document.json", &json!({}))).unwrap(); + let text = model.to_string(); + assert!(!text.contains("doc-brr-away") && !text.contains("Broken Band"), "{text}"); + assert!(text.contains("\"path\":\"photo.png\"") && text.contains("Photo Band"), "{text}"); + assert!(text.contains(&json!(near_pic).to_string()), "{text}"); + // The document keeps its brushes and links. + let stored = s.doc().unwrap().doc.unknown.get(vectorcraft_brush::DOC_KEY).unwrap().to_string(); + assert!(stored.contains("Broken Band") && stored.contains(&json!(pic).to_string()), "{stored}"); +} + /// Save As reads its defaults from the catalog; using them must retain current editable links. #[test] fn native_save_catalog_defaults_keep_placed_documents_editable() { diff --git a/docs/mcp.md b/docs/mcp.md index 47f425482f..aee76d3710 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -172,9 +172,11 @@ the suite) keep the agent to the folders you give it: read roots, and the preset stays. The preferences themselves are not changed. - `document.inspect` (`inspect_document`) gives `path` as null when the document's file is outside the read roots. `document.json` names a linked image or placed file outside them by its file name alone in the link's `path`, and - leaves out the link's `relative`, `size` and `modified`. It also names a data set's image value outside them by - its file name alone, and leaves out the `folder` of `export_settings` (the settings Export for Screens last used) - when that folder is outside them. The document itself is not changed. + leaves out the link's `relative`, `size` and `modified`. This covers the art of the brushes in `unknown.brushes` + too. A link whose file is inside them keeps its `path` but leaves out a `relative` that leads outside them. A + stored brush entry that does not read as a brush is left out of `unknown.brushes`. `document.json` also names a data set's image + value outside them by its file name alone, and leaves out the `folder` of `export_settings` (the settings Export + for Screens last used) when that folder is outside them. The document itself is not changed. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an output folder and a scratch folder, without widening a root to their common parent. A folder given twice diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d4a501dca5..df8e93326c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -764,7 +764,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Saves, Export for Screens and Package under `--automation-read-root`: a save without a path leaves the document's own file out of its reply, and a save without a path known leaves out the suggested folder, when they are outside the read roots; Export for Screens into an export preset's folder outside them lists its files relative to that folder and names that folder by its name in an error, and the export settings and saved presets leave out a folder outside them when read or exported; Package's report and warnings, and Document Info's linked images, name a file outside them by its file name. A path the client gives stays in the reply. `crates/engine/src/tests/save.rs`, `crates/engine/src/cmd/fileio/tests_screens.rs`, `crates/engine/src/tests/package.rs`, `crates/ui-egui/src/tests/automation.rs`. - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). - Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`). - - Document replies under `--automation-read-root`: `document.inspect` gives `path` as null for a document file outside the read roots, and `document.json` names a linked image or placed file outside them by its file name alone, without its relative path, size or date, and a data set's image value outside them by its file name, and leaves an Export for Screens folder outside them out of `export_settings`; the document is not changed. `crates/engine/src/tests/placed_document.rs` (`document_replies_leave_out_paths_outside_the_read_roots`). + - Document replies under `--automation-read-root`: `document.inspect` gives `path` as null for a document file outside the read roots, and `document.json` names a linked image or placed file outside them by its file name alone, without its relative path, size or date, brush art included, leaves out a relative path that leads outside them and a stored brush entry that does not read as a brush, names a data set's image value outside them by its file name, and leaves an Export for Screens folder outside them out of `export_settings`; the document is not changed. `crates/engine/src/tests/placed_document.rs` (`document_replies_leave_out_paths_outside_the_read_roots`, `document_json_checks_brush_art_and_relative_paths_against_the_read_roots`). - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. - Preferences commands under `--automation-read-root`: `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show folder preferences outside the read roots as `null` and leave out an export preset's folder outside them, and the preferences are not changed. `crates/engine/src/tests/prefs_roots.rs`. - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. From 3e46408ab572d2e61d1504e8ae37c06774f84e9f Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 09:25:14 -0800 Subject: [PATCH 58/92] EPS import: cshow and kshow procedures see the font PLRM gives them In a CID-keyed font whose CIDFont the file only names, a cshow procedure now runs with the CIDFont as the current font, rootfont gives the composite font, and the procedure gets each code's last byte. A show in the CIDFont shows the character of that code, and any other string is a rangecheck error. The current font is restored after each procedure. A kshow procedure no longer leaves a font it sets current: the current font is restored when the procedure completes, for every font, and a kshow the procedure runs sees the font the procedure set. In GBT-H and CNS2-H, whose mappings are not read, a byte that starts no row-and-cell code is a code of its own. docs/gaps.md dates this G9 work in a bullet of its own. --- crates/eps/src/import/interp.rs | 4 + crates/eps/src/import/predefined_cmap.rs | 7 +- crates/eps/src/import/tests_cid.rs | 57 +++++++++-- crates/eps/src/import/text.rs | 123 +++++++++++++++++------ docs/gaps.md | 3 +- 5 files changed, 150 insertions(+), 44 deletions(-) diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index 6b96aabc25..0ea7017c11 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -98,6 +98,9 @@ pub(crate) struct Interp<'a> { /// The font a `kshow` procedure sees as current while type in a CIDFont only named is set in /// a font of its own (see `text`). pub kshow_font: Option<DictRef>, + /// The `cshow` procedure running over type in a CIDFont only named, with the font it sees as + /// current (see `text`). + pub cshow: Option<super::text::CShowFont>, pub g: GState, pub saved: Vec<GState>, pub out: Out, @@ -240,6 +243,7 @@ impl<'a> Interp<'a> { cmap_font: 0, predefined_cmaps: vec![], kshow_font: None, + cshow: None, type42_counted: HashSet::new(), g, saved: vec![], diff --git a/crates/eps/src/import/predefined_cmap.rs b/crates/eps/src/import/predefined_cmap.rs index 19525fdf36..c688ebf9ae 100644 --- a/crates/eps/src/import/predefined_cmap.rs +++ b/crates/eps/src/import/predefined_cmap.rs @@ -288,7 +288,7 @@ fn code_space(cmap: &str) -> Option<CodeSpace> { "83pv-RKSJ" | "90pv-RKSJ" => RKSJ_MAC, _ if stem.ends_with("RKSJ") => RKSJ, "EUC" | "78-EUC" => EUC_JP, - "" | "78" | "Add" | "Ext" | "NWP" => ROW_CELL, + "" | "78" | "Add" | "Ext" | "NWP" | "GBT" | "CNS2" => ROW_CELL, "GB-EUC" | "GBT-EUC" | "KSC-EUC" => EUC, "GBpc-EUC" | "GBTpc-EUC" => GB_PC, "GBK-EUC" | "GBKp-EUC" => GBK, @@ -327,8 +327,9 @@ pub(super) fn known(cmap: &str) -> bool { /// The codes of string `bytes` in predefined CMap `cmap` when its mappings can't be read, each /// standing for no character: as long as the code space range it falls in ([`code_space`]; a byte -/// that starts no code is a code of its own), or two bytes when the code space isn't listed (the -/// row-and-cell codes of `GBT-H` and `CNS2-H`). A 0x0D or 0x09 byte is always a code of its own. +/// that starts no code is a code of its own, as in the row-and-cell code space of `GBT-H` and +/// `CNS2-H`), or two bytes when the code space isn't listed. A 0x0D or 0x09 byte is always a code +/// of its own. pub(super) fn unread_codes(cmap: &str, bytes: &[u8]) -> Decoded { let space = code_space(cmap); let mut out = Decoded::default(); diff --git a/crates/eps/src/import/tests_cid.rs b/crates/eps/src/import/tests_cid.rs index 25ed81c2b0..8350cd514b 100644 --- a/crates/eps/src/import/tests_cid.rs +++ b/crates/eps/src/import/tests_cid.rs @@ -487,21 +487,29 @@ fn named_cidfonts_over_predefined_cmaps_take_the_show_operators_spacing() { measured(&format!("{set} 10 20 moveto <93fa967b> [30 30] xshow currentpoint pop 70 sub abs 0.01 lt")); measured(&format!("{set} 10 20 moveto <93fa967b> [10 10] yshow currentpoint 40 sub abs 0.01 lt exch 10 sub abs 0.01 lt and")); measured(&format!("{set} 10 20 moveto <93fa967b> [30 10 30 10] xyshow currentpoint 40 sub abs 0.01 lt exch 70 sub abs 0.01 lt and")); - // `kshow` runs its procedure between each two codes: it moves the point 100 more here, and a - // font it sets stays current. + // `kshow` runs its procedure between each two codes: it moves the point 100 more here. The + // current font is restored when the procedure completes (PLRM 3rd ed., `kshow`), so a font it + // sets doesn't stay current, here or for a font found by name. measured(&format!( "{set} 10 20 moveto <93fa967b> show currentpoint 10 20 moveto {{pop pop 100 0 rmoveto}} <93fa967b> kshow currentpoint \ 3 -1 roll sub abs 0.01 lt 3 1 roll sub -100 sub abs 0.01 lt and" )); measured(&format!( - "{set} 10 20 moveto {{pop pop /Helvetica findfont 10 scalefont setfont}} <93fa967b> kshow currentfont /FontName get /Helvetica eq" + "{set} 10 20 moveto {{pop pop /Helvetica findfont 10 scalefont setfont}} <93fa967b> kshow currentfont /FontName get /Ryumin-Light-90ms-RKSJ-H eq" )); - // The procedure sees the font the file set as current, and the next one the font an earlier - // one set. + measured( + "/Helvetica findfont 20 scalefont setfont 10 20 moveto {pop pop /Courier findfont 10 scalefont setfont} (AB) kshow currentfont /FontName get /Helvetica eq", + ); + // Each procedure sees the font the file set as current, also after an earlier one set another. measured(&format!("{set} 10 20 moveto {{pop pop currentfont /FontName get /seen exch def}} <93fa967b> kshow seen /Ryumin-Light-90ms-RKSJ-H eq")); measured(&format!( "{set} 10 20 moveto /n 0 def {{pop pop /n n 1 add def n 1 eq {{/Helvetica findfont 10 scalefont setfont}} {{currentfont /FontName get /seen exch def}} ifelse}} \ - <93fa967b41> kshow seen /Helvetica eq" + <93fa967b41> kshow seen /Ryumin-Light-90ms-RKSJ-H eq" + )); + // A `kshow` the procedure runs in a font it set sees that font. + measured(&format!( + "{set} 10 20 moveto {{pop pop /Helvetica findfont 10 scalefont setfont {{pop pop currentfont /FontName get /seen exch def}} (AB) kshow}} \ + <93fa967b> kshow seen /Helvetica eq" )); // The procedure gets the two codes' values (left on the stack here). measured(&format!( @@ -575,8 +583,9 @@ fn named_cid_type_is_set_alike_by_every_show_operator() { /// `charpath`, `cshow` and `glyphshow` in a CID-keyed font over a predefined CMap, its CIDFont only /// named: `charpath` adds the outlines of the characters the codes stand for and moves the point -/// as `show` does; `cshow`'s procedure gets each code (two bytes for a two-byte code) with its -/// width; `glyphshow` sets the named character itself. +/// as `show` does; `cshow`'s procedure gets each code's last byte with its width and runs with the +/// CIDFont current, a show in it setting the code's character; `glyphshow` sets the named +/// character itself. #[test] fn named_cid_type_in_charpath_cshow_and_glyphshow() { let set = "/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont"; @@ -595,9 +604,35 @@ fn named_cid_type_in_charpath_cshow_and_glyphshow() { top("fd41"), top("41") )); - // The procedure leaves each code: 日 (0x93FA), then A. - check(&format!("{set} [ {{pop pop}} <93fa41> cshow ] dup length 2 eq exch dup 0 get 16#93fa eq exch 1 get 65 eq and and")); + // The procedure leaves each code's last byte (PLRM 3rd ed., `cshow`): 日 (0x93FA), then A. + check(&format!("{set} [ {{pop pop}} <93fa41> cshow ] dup length 2 eq exch dup 0 get 16#fa eq exch 1 get 65 eq and and")); check(&format!("{set} 0 {{pop exch pop add}} <93fa967b41> cshow <93fa967b41> stringwidth pop sub abs 0.01 lt")); + // While the procedure runs, the current font is the CIDFont and `rootfont` gives the composite + // font (PLRM 3rd ed., `cshow` and `rootfont`); the current font is restored after each run, + // also when the procedure sets another. + check(&format!( + "{set} {{pop pop pop currentfont dup /FontType get /t exch def /CIDFontName get /seen exch def}} <93fa> cshow \ + seen /Ryumin-Light eq t 9 eq and" + )); + check(&format!("{set} {{pop pop pop rootfont /FontName get /seen exch def}} <93fa> cshow seen /Ryumin-Light-90ms-RKSJ-H eq")); + check(&format!( + "{set} /n 0 def {{pop pop pop /n n 1 add def n 1 eq {{/Helvetica findfont 10 scalefont setfont rootfont /FontName get /r exch def}} \ + {{currentfont /CIDFontName get /seen exch def}} ifelse}} <93fa41> cshow seen /Ryumin-Light eq r /Helvetica eq and \ + currentfont /FontName get /Ryumin-Light-90ms-RKSJ-H eq and" + )); + // A show in the CIDFont shows the character of the code the procedure was given, the string + // holding its last byte, and moves the point as far as `show` does. Any other string, an + // empty one included, is a `rangecheck` error. + for s in ["()", "(AB)", "<41>"] { + check(&format!("gsave nulldevice {set} 10 20 moveto {{{{pop pop pop {s} show}} <93fa> cshow}} stopped grestore")); + } + check(&format!("gsave nulldevice {set} 10 20 moveto {{{{pop pop pop <fa> show}} <93fa> cshow}} stopped not grestore")); + let r = super::tests::read(&format!("{set} 10 20 moveto {{pop pop ( ) dup 0 4 -1 roll put show}} <93fa967b> cshow")); + assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::<String>(), "日本"); + check(&format!( + "gsave nulldevice {set} 10 20 moveto <93fa967b> show currentpoint 10 20 moveto {{pop pop ( ) dup 0 4 -1 roll put show}} <93fa967b> cshow \ + currentpoint grestore 3 -1 roll sub abs 0.01 lt 3 1 roll sub abs 0.01 lt and" + )); let r = super::tests::read(&format!("{set} 10 20 moveto /uni00E9 glyphshow")); assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["é"]); } @@ -640,6 +675,8 @@ fn latin_names_ending_in_h_and_cmaps_that_cannot_be_read() { "/Ryumin-Light-GBT-V findfont 20 scalefont setfont 10 100 moveto <21212121> show currentpoint 60 sub abs 0.01 lt exch 10 sub abs 0.01 lt and", // A tab is a code of its own: three codes. "/Ryumin-Light-GBT-H findfont 20 scalefont setfont <09092121> stringwidth abs 0.01 lt exch 60 sub abs 0.01 lt and", + // A byte that starts no row-and-cell code is a code of its own, also before a tab: three. + "/Ryumin-Light-GBT-H findfont 20 scalefont setfont <21092121> stringwidth abs 0.01 lt exch 60 sub abs 0.01 lt and", ] { let r = super::tests::read(&format!("{cond} {{ 0 0 10 10 rectfill }} if")); assert!(all(&r.document).iter().any(|n| matches!(n.kind, NodeKind::Path { .. })), "{cond}"); diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index 73fc4776d0..989450f440 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -186,22 +186,15 @@ impl Interp<'_> { // The procedure shows or places each character: it gets its code and width. let s = self.pop_str()?.to_vec(); let proc = self.pop_proc()?; - // In a CIDFont only named, a code may be several bytes: the procedure gets each - // code's value. + // In a CIDFont only named, a code may be several bytes: the procedure runs once for + // each code. if let Some(named) = self.named_cid_type(&s)? { - let mut chars = named.codes.text.chars(); - for &(code, n) in &named.codes.codes { - let mut one = Decoded::default(); - one.push(code, chars.by_ref().take(n)); - let v = self.named_cid_width(&named, &one)?; - self.push(Obj::Int(i64::from(code)))?; - self.push_num(v.x)?; - self.push_num(v.y)?; - if !self.body(&proc)? { - break; - } - } - return Ok(()); + let root = self.g.font.clone(); + let before = self.cshow.take(); + let r = self.named_cshow(named, &proc, root.clone()); + self.cshow = before; + self.g.font = root; + return r; } for b in s { let v = self.width_of(&[b])?; @@ -252,7 +245,12 @@ impl Interp<'_> { SetCharWidth => self.glyph_width = Some(self.nums::<2>()?), SetCacheDevice2 => self.glyph_width = Some(self.nums::<10>()?[..2].try_into().unwrap_or_default()), RootFont => { - let font = match self.g.font.clone() { + // Inside a `cshow` procedure the composite font, unless the procedure set a font. + let root = match (&self.cshow, &self.g.font) { + (Some(c), Some(f)) if f.same(&c.selected) => Some(c.root.clone()), + _ => None, + }; + let font = match root.or_else(|| self.g.font.clone()) { Some(f) => f, None => self.find_font(&Obj::name(DEFAULT_FONT))?, }; @@ -791,18 +789,16 @@ impl Interp<'_> { self.push(Obj::Int(code.into()))?; self.push(Obj::Int(next.into()))?; // The procedure sees the font the file set, not the one type in a CIDFont only named is - // set in; a font it sets stays current. - let Some(outer) = self.kshow_font.take() else { - self.call(p.clone())?; - return Ok(self.user_point()? - pen); + // set in, and the current font is restored when it completes (PLRM 3rd ed., `kshow`). + // A `kshow` the procedure runs sees the font the procedure set. + let outer = self.kshow_font.take(); + let inner = match outer.clone() { + Some(outer) => self.g.font.replace(outer), + None => self.g.font.clone(), }; - let inner = self.g.font.replace(outer.clone()); let r = self.call(p.clone()); - // Once the procedure sets a font, it and the later procedures see that one. - if self.g.font.as_ref().is_some_and(|f| f.same(&outer)) { - self.g.font = inner; - self.kshow_font = Some(outer); - } + self.kshow_font = outer; + self.g.font = inner; r?; Ok(self.user_point()? - pen) } @@ -960,6 +956,15 @@ struct NamedCid { ems: bool, } +/// A `cshow` procedure running over type in a CIDFont only named ([`Interp::named_cshow`]): the +/// CIDFont it sees as current, the composite font `rootfont` gives while that CIDFont is current, +/// and the type of the code it was given. +pub(super) struct CShowFont { + selected: DictRef, + root: DictRef, + code: NamedCid, +} + /// What drawing a glyph does with it. #[derive(Clone, Copy, PartialEq)] enum Draw { @@ -1092,7 +1097,17 @@ impl Interp<'_> { /// stands for none), set as type in the font of its CIDFont's name, vertical over a vertical /// CMap (`WMode` 1). Control codes stand for no character. Type in a CMap VectorCraft can't /// read is left out, with a warning, each of its codes moving the point one em. + /// Inside a `cshow` procedure over such type, with the CIDFont current, the string must be the + /// one byte the procedure was given, else a `rangecheck` error, and it stands for the code + /// the procedure was given (PLRM 3rd ed., `cshow`: the glyph of the selected CID). fn named_cid_type(&mut self, s: &[u8]) -> Res<Option<NamedCid>> { + if let Some(named) = self.cshow_code(true) { + let given = named.codes.codes.first().map(|&(code, _)| code & 0xff); + if s.len() != 1 || given != s.first().map(|&b| u32::from(b)) { + return ps_err("rangecheck", "a string shown in cshow"); + } + return Ok(Some(named)); + } let Some(cmap) = self.named_cid_parts().map(|(cmap, ..)| cmap) else { return Ok(None) }; // Each code is looked up in the CMap's mappings. self.spend(s.len() as u64)?; @@ -1120,6 +1135,9 @@ impl Interp<'_> { /// The font [`Self::named_cid_type`] sets type in, with no codes yet, when the current font is /// a CID-keyed font over a predefined CMap the file doesn't define. fn named_cid_font(&mut self) -> Res<Option<NamedCid>> { + if let Some(named) = self.cshow_code(false) { + return Ok(Some(named)); + } let Some((_, font, name, vertical)) = self.named_cid_parts() else { return Ok(None) }; // The composite font is copied under the CIDFont's name for every string: charged by // its size, so a huge font dictionary shown in a loop runs out of the budget. @@ -1130,6 +1148,23 @@ impl Interp<'_> { Ok(Some(NamedCid { codes: Decoded::default(), font: DictRef::new(named), vertical, ems: vertical })) } + /// The type of the code a running `cshow` procedure was given ([`Self::named_cshow`]), with + /// that code when `code` and with none otherwise, while the CIDFont it selected is current. + fn cshow_code(&self, code: bool) -> Option<NamedCid> { + let c = self.cshow.as_ref()?; + if !self.g.font.as_ref().is_some_and(|f| f.same(&c.selected)) { + return None; + } + let mut codes = Decoded::default(); + if code { + let mut chars = c.code.codes.text.chars(); + for &(value, n) in &c.code.codes.codes { + codes.push(value, chars.by_ref().take(n)); + } + } + Some(NamedCid { codes, font: c.code.font.clone(), vertical: c.code.vertical, ems: c.code.ems }) + } + /// The CMap's name, the composite font, the CIDFont's name and the writing mode of /// [`Self::named_cid_type`]. fn named_cid_parts(&self) -> Option<(String, DictRef, Obj, bool)> { @@ -1191,13 +1226,41 @@ impl Interp<'_> { let before = std::mem::replace(&mut self.kshow_font, saved.clone()); let r = self.show_codes(&named.codes, widths.as_deref(), spacing, named.vertical, &kern, draw); self.kshow_font = before; - // A font the `kshow` procedure set stays current. - if self.g.font.as_ref().is_none_or(|f| f.same(&named.font)) { - self.g.font = saved; - } + self.g.font = saved; r } + /// `cshow`'s procedure `proc` run for each code of type `named` in composite font `root`, with + /// the code's last byte and its width (PLRM 3rd ed., `cshow`). While it runs the CIDFont is the + /// current font and `rootfont` gives `root`; a show operator in the CIDFont shows the code's + /// character, whatever its string (see [`Self::named_cid_type`]). The current font is restored + /// when the procedure completes. + fn named_cshow(&mut self, named: NamedCid, proc: &Obj, root: Option<DictRef>) -> Res { + let Some(composite) = root.clone() else { return Ok(()) }; + let dep = named.font.borrow().get(&Key::name("FDepVector")).and_then(|v| v.items()?.get(0)); + let selected = match dep { + Some(Obj::Dict(d)) => d, + _ => named.font.clone(), + }; + let mut chars = named.codes.text.chars(); + for &(code, n) in &named.codes.codes { + let mut one = NamedCid { codes: Decoded::default(), font: named.font.clone(), vertical: named.vertical, ems: named.ems }; + one.codes.push(code, chars.by_ref().take(n)); + let v = self.named_cid_width(&one, &one.codes)?; + self.push(Obj::Int(i64::from(code & 0xff)))?; + self.push_num(v.x)?; + self.push_num(v.y)?; + self.cshow = Some(CShowFont { selected: selected.clone(), root: composite.clone(), code: one }); + self.g.font = Some(selected.clone()); + let go = self.body(proc); + self.g.font = root.clone(); + if !go? { + break; + } + } + Ok(()) + } + /// How far codes `codes` of type `named` move the current point (user space) when /// [`Self::show_named_cid`] shows them with no extra spacing. fn named_cid_width(&mut self, named: &NamedCid, codes: &Decoded) -> Res<Vec2> { diff --git a/docs/gaps.md b/docs/gaps.md index 09c31c8ae8..c668a3f9c9 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -195,7 +195,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Narrowed (#6165, 2026-11-09):** editable PDF import preserves explicit ActualText once, with empty/nested spans, authored order and glyph advances covered by `crates/pdf/src/tests/actual_text.rs`. One replacement uses the first placed glyph's line/style; exact redistribution across lines/styles and general mixed-direction layout remain. Input is bounded to depth 128 and 64 KiB encoded per accepted replacement. This does not close G9's broader `.ai`/EPS gaps or change its estimate. -- **Done (2026-11-09):** the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined CMaps (Unicode, Shift-JIS, EUC, GBK, Big Five, UHC) set as type, vertical over `-V` CMaps) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. This narrows G9. +- **Narrowed (2026-11-12):** named CIDFonts over predefined CMaps other than Unicode CMaps (Shift-JIS, EUC, GBK, Big Five, UHC) are set as type too, vertical over `-V` CMaps, also by `charpath`, `cshow`, `kshow` and `glyphshow`. This narrows G9; scores and estimates unchanged. +- **Done (2026-11-09):** the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. This narrows G9. - **Done (2026-11-06):** an EPS page that paints its gradients as rows of abutting one-colour strips imports them as linear gradients, so the seams between strips no longer show and an Illustrator EPS drawn that way keeps its layers instead of opening as its page. This narrows G9; scores and estimates unchanged. - **Done (2026-10-30):** from an audit of a user's `.ai`/EPS files: All Caps and Small Caps; placed files at their box's corner; pages cropped to the art; gradient mesh corners with handle weights of 0 and the transparency written after a mesh, a masked mesh and a blend; a layer's state block with the stroke style; type on a path spaced as the file lays it out (the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box, and the fit under Gravity does not reproduce the file's layout); EPS10 legacy-format type; a PDF of some artboards compared on the artboard its page shows. Comparison limits remain unchanged. This narrows G9; scores and estimates unchanged. From 6213d1c471a5ea0c8395d88b6556adcd5116e2dc Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 08:56:23 -0800 Subject: [PATCH 59/92] EPS tests: the reader's long tests check counts and shared state instead of a 20 s limit many_tiling_patterns_and_spot_inks_read_in_linear_time checked that a program setting 200,000 different tiling patterns and 200,001 spot inks reads within 20 s of wall-clock time. It reads in about 4 s alone; while the crate's full test suite ran in parallel it took more than 20 s and failed. In test builds, the tiling pattern lookup now counts on its thread how many patterns it compared with the one it looked for (tilings_compared, gated with cfg(test) like SELECTOR_TESTS in the SVG reader's CSS). The test, renamed many_tiling_patterns_are_found_again_through_a_hash, reads through import on a thread of its own and asserts that its 200,000 lookups compared at most 200,000 patterns in all. When every pattern hashes alike, as when each lookup went through every pattern made so far, the lookups compare 19,999,900,000 and the test fails. The test still reads the 200,001 spot inks and checks their swatches, but it does not count or time their lookups. The other tests that read through read_soon wait 300 s instead of 20 s, as a guard against a read that never ends; their budgets and limits decide what they check. work_on_the_longest_dash_pattern_ends_soon (4.5 s alone) now also checks that the states gsave, grestore, gstate, currentgstate and setgstate save, make or set hold the one 65,535-entry dash pattern, so a copy of the pattern in any of them fails it. --- crates/eps/src/import/graphics.rs | 2 + crates/eps/src/import/graphics/paint.rs | 21 ++++++- crates/eps/src/import/tests.rs | 75 ++++++++++++++++++++----- 3 files changed, 82 insertions(+), 16 deletions(-) diff --git a/crates/eps/src/import/graphics.rs b/crates/eps/src/import/graphics.rs index 46fd148db9..3ff736d1ff 100644 --- a/crates/eps/src/import/graphics.rs +++ b/crates/eps/src/import/graphics.rs @@ -24,6 +24,8 @@ mod state; mod strips; pub(crate) use paint::collapse; +#[cfg(test)] +pub(crate) use paint::tilings_compared; pub(crate) use state::{GState, Out, Space, process}; pub(crate) use strips::merge_strips; diff --git a/crates/eps/src/import/graphics/paint.rs b/crates/eps/src/import/graphics/paint.rs index fc2490fb41..156b6e421e 100644 --- a/crates/eps/src/import/graphics/paint.rs +++ b/crates/eps/src/import/graphics/paint.rs @@ -5,6 +5,19 @@ use super::{ PsError, REGISTRATION, Rc, Rect, Res, SHADING_SAMPLES, Shared, UNKNOWN_FUNCTIONS, UNKNOWN_SHADINGS, Vec2, clamp_between, ps_err, }; +#[cfg(test)] +thread_local! { + /// How many patterns the lookups of tiling patterns on this thread compared with the pattern + /// they looked for. + static TILINGS_COMPARED: std::cell::Cell<u64> = const { std::cell::Cell::new(0) }; +} + +/// [`TILINGS_COMPARED`] of this thread. +#[cfg(test)] +pub(crate) fn tilings_compared() -> u64 { + TILINGS_COMPARED.get() +} + impl Interp<'_> { // ---------- colour ---------- @@ -212,7 +225,13 @@ impl Interp<'_> { let proc = get("PaintProc").ok_or(PsError::Ps("undefined", "PaintProc".into()))?; let key = proc.items().cloned().ok_or(PsError::Ps("typecheck", "PaintProc".into()))?; let hash = tiling_hash(&key, tile, under.as_ref()); - let made = self.out.tilings.get(&hash).and_then(|list| list.iter().find(|(p, t, u, _)| p.same(&key) && *t == tile && *u == under)); + let made = self.out.tilings.get(&hash).and_then(|list| { + list.iter().find(|(p, t, u, _)| { + #[cfg(test)] + TILINGS_COMPARED.set(TILINGS_COMPARED.get().saturating_add(1)); + p.same(&key) && *t == tile && *u == under + }) + }); if let Some((.., name)) = made { return Ok(Paint::Pattern { pattern: name.clone(), xf }); } diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index 6a1df810bc..4709a3c5bf 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -7,6 +7,9 @@ use vectorcraft_color::{Color, GradientKind, Paint}; use vectorcraft_doc::{AppearanceItem, ColorMode, Document, LineCap, LineJoin, Node, NodeKind}; use vectorcraft_geom::{Point, Rect}; +use crate::import::graphics::{GState, Out, tilings_compared}; +use crate::import::interp::Interp; +use crate::import::obj::Obj; use crate::import::{Dsc, import}; use crate::{EpsOptions, Level, Preview, Raster}; @@ -369,12 +372,19 @@ fn dash_patterns_hold_at_most_65535_entries() { /// Saved and copied graphics states share the dash pattern, and `setdash`, `currentdash` and /// each stroke count the entries they read or copy against the budgets. Under a pattern of -/// 65,535 entries, 4,000,000 passes of `gsave grestore` and 2,000,000 passes of -/// `gstate currentgstate setgstate` finish, and loops of `setdash`, `currentdash` and `strokepath` -/// run out of a budget. +/// 65,535 entries, the states that `gsave`, `grestore`, `gstate`, `currentgstate` and `setgstate` +/// save, make or set hold that one pattern, 4,000,000 passes of `gsave grestore` and 2,000,000 +/// passes of `gstate currentgstate setgstate` finish, and loops of `setdash`, `currentdash` and +/// `strokepath` run out of a budget. #[test] fn work_on_the_longest_dash_pattern_ends_soon() { let dash = "0 0 10 10 rectfill /a 65535 array def 0 1 65534 { a exch 1 put } for a 0 setdash"; + let shared = run_looking(&format!("{dash} gsave gsave grestore gstate gstate currentgstate dup setgstate"), |it| { + let Some(saved) = it.saved.last().map(|g| g.dash.clone()) else { return false }; + let made: Vec<_> = it.stack.iter().filter_map(|o| if let Obj::GState(g) = o { Some(g.dash.clone()) } else { None }).collect(); + saved.len() == 65_535 && made.len() == 2 && made.iter().chain([&it.g.dash]).all(|m| std::rc::Rc::ptr_eq(m, &saved)) + }); + assert!(shared); for body in ["4000000 { gsave grestore } repeat", "2000000 { gstate currentgstate setgstate } repeat"] { let r = read_soon(&format!("{dash} {body} 20 20 10 10 rectfill")); assert_eq!(objects(&r.document).len(), 2, "{body}: {:?}", r.warnings); @@ -530,13 +540,15 @@ fn non_finite_arithmetic_is_undefinedresult() { assert!(!objects(&r.document).is_empty(), "{:?}", r.warnings); } -/// Tiling patterns and spot inks are found again through a hash of what tells them apart, so a -/// program that makes 200,000 patterns and 200,001 inks reads within [`read_soon`]'s 20 seconds. -/// Patterns are named Pattern 1, Pattern 2 and so on in the order they are made. A pattern made -/// again from its prototype is the same pattern, and so is an uncolored pattern set again in a -/// color it was set in before. +/// Tiling patterns are found again through a hash of what tells them apart. A program that sets +/// 200,000 different patterns and also sets 200,001 spot inks reads them all, and its 200,000 +/// pattern lookups compare at most 200,000 earlier patterns in all; going through every pattern +/// made so far would compare about 2 × 10^10. The test does not count or time the spot ink +/// lookups. Patterns are named Pattern 1, Pattern 2 and so on in the order they are made. A +/// pattern made again from its prototype is the same pattern, and so is an uncolored pattern set +/// again in a color it was set in before. #[test] -fn many_tiling_patterns_and_spot_inks_read_in_linear_time() { +fn many_tiling_patterns_are_found_again_through_a_hash() { let r = read( "/pp { pop 0 0 1 1 rectfill } def \ /a << /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 1 1] /XStep 2 /YStep 2 /PaintProc /pp load >> def \ @@ -560,7 +572,7 @@ fn many_tiling_patterns_and_spot_inks_read_in_linear_time() { assert!(names.iter().all(|n| d.swatch(n).is_some_and(|s| matches!(s.paint, Paint::Pattern { .. })))); // 100,000 patterns whose cells differ, an uncolored pattern in 100,000 tints of an ink that is // white at every tint (only the tint tells those patterns apart), and 200,000 more inks. - let r = read_soon( + let (r, compared) = read_counting_tilings( "0 1 99999 { /i exch def << /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 1 1] /XStep i 2 add /YStep 1 \ /PaintProc { pop 0 0 1 1 rectfill } >> matrix makepattern setpattern } for \ [/Separation (Spot) /DeviceGray { pop 1 }] setcolorspace \ @@ -568,6 +580,7 @@ fn many_tiling_patterns_and_spot_inks_read_in_linear_time() { matrix makepattern def 0 1 99999 { 100000 div p setpattern } for \ 0 1 199999 { /i exch def [/Separation i 8 string cvs /DeviceGray {}] setcolorspace } for 0 0 1 1 rectfill", ); + assert!(compared <= 200_000, "{compared} patterns compared"); let d = &r.document; assert_eq!(d.patterns.len(), 200_000); assert!(d.patterns.iter().enumerate().all(|(i, p)| p.name == format!("Pattern {}", i + 1))); @@ -1112,19 +1125,51 @@ fn hostile_executable_files_and_type_loops_are_refused() { blue("currentfile cvx exec currentfile flushfile 0 0 1 setrgbcolor"); } -/// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading takes -/// more than 20 s. +/// How long [`read_soon`] and [`read_counting_tilings`] wait, in seconds: a guard against a read +/// that never ends. The budgets, limits and counts each test names decide what it checks. +const NEVER_ENDS: u64 = 300; + +/// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading has not +/// finished after [`NEVER_ENDS`] seconds. fn read_soon(body: &str) -> crate::Imported { - read_within(body, 20) + read_within(body, NEVER_ENDS) } /// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading takes /// more than `secs` seconds. fn read_within(body: &str, secs: u64) -> crate::Imported { + read_counting(body, secs).0 +} + +/// [`read_soon`], with how many patterns the tiling pattern lookups compared while reading. +fn read_counting_tilings(body: &str) -> (crate::Imported, u64) { + read_counting(body, NEVER_ENDS) +} + +/// [`read_within`], with how many patterns the tiling pattern lookups compared while reading. +fn read_counting(body: &str, secs: u64) -> (crate::Imported, u64) { let (tx, rx) = std::sync::mpsc::channel(); let ps = eps(body); - std::thread::spawn(move || tx.send(import(&ps)).ok()); - rx.recv_timeout(std::time::Duration::from_secs(secs)).unwrap_or_else(|_| panic!("reading {body} did not finish")).unwrap() + std::thread::spawn(move || tx.send((import(&ps), tilings_compared())).ok()); + let (read, compared) = rx.recv_timeout(std::time::Duration::from_secs(secs)).unwrap_or_else(|_| panic!("reading {body} did not finish")); + (read.unwrap(), compared) +} + +/// Runs the program of `body` in an interpreter of its own on the 100 × 100 page of [`eps`]'s +/// bounding box, and returns what `look` finds in the interpreter when the program has ended. It +/// does not read the file's comments or make a document. The test fails when the program stops on +/// an error. +fn run_looking<T>(body: &str, look: fn(&Interp) -> T) -> T { + let file = eps(body); + let ps = super::program(&file).unwrap(); + let mut doc = Document::new(100.0, 100.0); + doc.layers.clear(); + let page = vectorcraft_geom::Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 100.0]); + let mut it = Interp::new(ps, GState::default(), Out::new(doc, page, Rect::new(0.0, 0.0, 100.0, 100.0))); + assert!(it.run().is_ok(), "running {body} failed"); + let found = look(&it); + it.release(); + found } /// Reads `body` after a square: the square stays, and the program runs out of the operation From 4b9a611189047eeb691c362ca8fe206676708bb6 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 08:30:44 -0800 Subject: [PATCH 60/92] artboard.new: keep sides below 1 pt as given artboard.new no longer raises a width or height below 1 pt to 1 pt, and artboard.setProps raises only a supplied side, so an edit to a small artboard's name or lock keeps its size. --- crates/engine/src/cmd/layer.rs | 17 +++++++++++++---- crates/engine/src/tests/newdoc.rs | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 43d9a8e05f..f93cdca7b9 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -850,14 +850,23 @@ fn move_node(s: &mut Session, p: &Value) -> Result<Value> { ok() } -fn rect_from(p: &Value, default: Rect) -> Rect { +/// The rectangle from `x`, `y`, `width` and `height`, each defaulting to `default`'s. Sides are kept as given. +fn rect_unclamped(p: &Value, default: Rect) -> Rect { let x = f64_or(p, "x", default.x0); let y = f64_or(p, "y", default.y0); - let w = f64_or(p, "width", default.width()).max(1.0); - let h = f64_or(p, "height", default.height()).max(1.0); + let w = f64_or(p, "width", default.width()); + let h = f64_or(p, "height", default.height()); Rect::new(x, y, x + w, y + h) } +/// [`rect_unclamped`] with each supplied side raised to at least 1 pt, as `artboard.setProps` uses it. +/// A side left out keeps `default`'s, even below 1 pt. +fn rect_from(p: &Value, default: Rect) -> Rect { + let r = rect_unclamped(p, default); + let floor = |key: &str, side: f64| if p.get(key).and_then(Value::as_f64).is_some() { side.max(1.0) } else { side }; + Rect::new(r.x0, r.y0, r.x0 + floor("width", r.width()), r.y0 + floor("height", r.height())) +} + /// An artboard command's `color` param, its background color: None when absent, `Some(None)` for /// null (no color), else `Some(Some(color))`. fn artboard_color(p: &Value, cmd: &str) -> Result<Option<Option<vectorcraft_color::Color>>> { @@ -942,7 +951,7 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> { src + side.offset(src) } None if p.get("beside").is_some_and(|v| !v.is_null()) => return Err(bad("artboard.new", "`beside` needs `side`")), - None => rect_from(p, def), + None => rect_unclamped(p, def), }; // A size taken from another artboard (`side`, or width or height left out) keeps to the limit too. if !super::newdoc::side_in_range(r.width()) || !super::newdoc::side_in_range(r.height()) { diff --git a/crates/engine/src/tests/newdoc.rs b/crates/engine/src/tests/newdoc.rs index 0b573a96f3..d2e5f1b115 100644 --- a/crates/engine/src/tests/newdoc.rs +++ b/crates/engine/src/tests/newdoc.rs @@ -291,6 +291,20 @@ fn file_new_and_artboard_new_take_the_same_largest_side() { assert!(err.contains(in_meters), "{p}: {err}"); } assert_eq!(boards(&s).len(), 2); + + // A side below 1 pt is kept as given, as file.new keeps it, and a size taken from such an artboard keeps it too. + let mut s = new_doc(json!({"width": 0.5, "height": 0.25})); + assert_eq!(boards(&s), [Rect::new(0.0, 0.0, 0.5, 0.25)]); + s.execute("artboard.new", &json!({"x": 0, "y": 10, "width": 0.5, "height": 0.75})).unwrap(); + s.execute("artboard.new", &json!({"x": 0, "y": 20})).unwrap(); + assert_eq!(boards(&s)[1..], [Rect::new(0.0, 10.0, 0.5, 10.75), Rect::new(0.0, 20.0, 0.5, 20.75)]); + // artboard.setProps keeps such sides when it changes something else, on a locked artboard too, + // and still raises a supplied side to 1 pt. + s.execute("artboard.setProps", &json!({"index": 1, "name": "Small"})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 2, "locked": true})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 2, "name": "Small locked"})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 0, "height": 0.5})).unwrap(); + assert_eq!(boards(&s), [Rect::new(0.0, 0.0, 0.5, 1.0), Rect::new(0.0, 10.0, 0.5, 10.75), Rect::new(0.0, 20.0, 0.5, 20.75)]); } #[test] From 812cf5f4cd6ca5b864436ed2b1e71e176339f6c0 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 09:40:31 -0800 Subject: [PATCH 61/92] .ai save: RGB values are written in the working RGB space Plain RGB fills and strokes and RGB gradient stops were written with their display RGB values. The reader reads those values as working RGB, so with a working RGB space other than sRGB a reopened file was saved with different RGB values and CMYK equivalents. They are now written with the color's own RGB values, as global process and spot colors already were. A spot color in a gradient stop now writes its RGB values from its full color as the palette writes it (Lab to four decimals and clamped), the same values its CMYK equivalent comes from. --- crates/eps/src/ai_editing/tests.rs | 83 ++++++++++++++++++++++ crates/eps/src/ai_editing/writer.rs | 8 +-- crates/eps/src/ai_editing/writer/paints.rs | 15 ++-- crates/eps/src/ai_ops.rs | 9 +++ 4 files changed, 105 insertions(+), 10 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index a486feb039..42b2ab6310 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -325,6 +325,8 @@ fn cmyk_equivalents_are_written_again_unchanged() { doc.insert(Some(layer), usize::MAX, n).unwrap(); } let mut stops: Vec<GradientStop> = (0..5u16).map(|i| GradientStop::new(f32::from(i) / 5.0, rgb(i + 50))).collect(); + let out = named[1].1; + stops.push(GradientStop { swatch: Some("Out".into()), tint: 0.5, ..GradientStop::new(0.9, out.tinted(0.5)) }); stops.push(GradientStop { swatch: Some("Glaze".into()), tint: 0.7, ..GradientStop::new(1.0, glaze.tinted(0.7)) }); let mut fills = vec![gradient_paint(Gradient::new(GradientKind::Linear, stops), None, None)]; fills.extend(named.iter().map(|(name, c, spot)| linked(name, *c, if *spot { 1.0 } else { 0.6 }))); @@ -339,6 +341,87 @@ fn cmyk_equivalents_are_written_again_unchanged() { same_lines(&again.lines().map(String::from).collect::<Vec<_>>(), &first.lines().map(String::from).collect::<Vec<_>>()); } +/// With a working RGB space other than sRGB, an RGB fill, an RGB gradient stop and an RGB spot +/// color in a stop are written with their working RGB values, the values the reader reads back, so +/// the reopened file is written with the same RGB values and CMYK equivalents. +#[test] +fn rgb_values_are_written_again_unchanged_in_a_wide_gamut_working_space() { + const CHILD: &str = "VECTORCRAFT_TEST_AI_EDITING_WIDE_GAMUT"; + if std::env::var_os(CHILD).is_none() { + // Color settings are process-wide: exercise this profile in a separate test process. + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "ai_editing::tests::rgb_values_are_written_again_unchanged_in_a_wide_gamut_working_space", "--nocapture"]) + .env(CHILD, "1") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"), + "isolated color profile test failed:\n{stdout}\n{}", + String::from_utf8_lossy(&output.stderr), + ); + return; + } + use vectorcraft_color::cms; + cms::set_active(&cms::ColorSettings { rgb: cms::WIDE_GAMUT_RGB.into(), ..Default::default() }).unwrap(); + assert!(!cms::active().rgb_is_srgb()); + let mut doc = Document::new(300.0, 200.0); + let glaze = Color::rgb(0.812_345_6, 0.234_567_8, 0.456_789_1); + let mut swatches: Vec<Swatch> = doc.swatches.iter().cloned().collect(); + swatches.push(Swatch { name: "Glaze".into(), paint: solid(glaze), global: true, spot: true }); + doc.swatches = swatches.into(); + let layer = doc.layers[0].id; + let n = square(&mut doc, Rect::new(10.0, 10.0, 30.0, 30.0), Color::rgb(0.8, 0.4, 0.2)); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let stops = vec![ + GradientStop::new(0.0, Color::rgb(0.1, 0.7, 0.3)), + GradientStop { swatch: Some("Glaze".into()), tint: 0.7, ..GradientStop::new(1.0, glaze.tinted(0.7)) }, + ]; + let fill = gradient_paint(Gradient::new(GradientKind::Linear, stops), None, None); + let n = painted(&mut doc, shapes::rectangle(Rect::new(40.0, 10.0, 60.0, 30.0)), vec![AppearanceItem::Fill(FillLayer::new(fill))]); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let first = program(&doc); + assert!(first.contains("0.8 0.4 0.2 Xa"), "the fill's working RGB values are written"); + let again = String::from_utf8(export_ai_editing(&read_back(&doc), &opts()).unwrap().program).unwrap().replace("\r\n", "\n"); + same_lines(&again.lines().map(String::from).collect::<Vec<_>>(), &first.lines().map(String::from).collect::<Vec<_>>()); +} + +/// A gradient stop of a Lab spot color writes the RGB values of the Lab values as written (to four +/// decimals, clamped to the range a reader reads), the same Lab values its CMYK equivalent and the +/// palette's definition come from: for Lab values of many decimals and for Lab values out of range. +#[test] +fn a_lab_spot_stop_writes_rgb_values_of_its_lab_values_as_written() { + let mut doc = Document::new(300.0, 200.0); + let v = |i: u16, s: f32| (f32::from(i) * s + 0.123_456_7).fract(); + let mut spots: Vec<(String, Color)> = vec![("Out".into(), Color::lab(104.567_89, -140.123_46, 131.987_65))]; + spots.push(("Rim".into(), Color::lab(55.555_55, 131.234_56, -12.345_67))); + for i in 0..8u16 { + spots + .push((format!("Moss {i}"), Color::lab(v(i, 0.137_911_3) * 100.0, v(i, 0.271_828_2) * 200.0 - 100.0, v(i, 0.314_159_3) * 200.0 - 100.0))); + } + let mut swatches: Vec<Swatch> = doc.swatches.iter().cloned().collect(); + swatches.extend(spots.iter().map(|(name, c)| Swatch { name: name.clone(), paint: solid(*c), global: true, spot: true })); + doc.swatches = swatches.into(); + let layer = doc.layers[0].id; + for (i, (name, c)) in spots.iter().enumerate() { + let stops = vec![ + GradientStop::new(0.0, Color::rgb(0.1, 0.2, 0.3)), + GradientStop { swatch: Some(name.clone()), tint: 0.5, ..GradientStop::new(1.0, c.tinted(0.5)) }, + ]; + let fill = gradient_paint(Gradient::new(GradientKind::Linear, stops), None, None); + let x = i as f64 * 25.0 + 10.0; + let n = painted(&mut doc, shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)), vec![AppearanceItem::Fill(FillLayer::new(fill))]); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let program = program(&doc); + for (name, c) in &spots { + let mut want = String::new(); + crate::ps::push_nums(&mut want, &crate::ai_ops::as_written(*c).to_rgb().map(|v| f64::from(crate::ai_ops::unit(v)))); + want.push_str(&format!("({name}) 0.5 1 4 ")); + assert!(program.lines().any(|l| l.contains(&want)), "no stop of {name} writes {want:?}"); + } +} + #[test] fn the_data_is_zstandard_compressed_with_the_header_as_metadata() { let doc = sample(); diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index d5eeaeb08c..5a8ad12912 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -1343,9 +1343,9 @@ impl<'a> Writer<'a> { self.plain_color(*color, stroke); } - /// A color that names no swatch: `g` (gray), `k` (CMYK), else `c m y k r g b Xa` (RGB, and - /// Lab as RGB), with its CMYK equivalent (an RGB color's from its values as written, a Lab - /// color's from its Lab values). + /// A color that names no swatch: `g` (gray), `k` (CMYK), else `c m y k r g b Xa` (RGB in the + /// working RGB space, and Lab as display RGB), with its CMYK equivalent (an RGB color's from its + /// values as written, a Lab color's from its Lab values). fn plain_color(&mut self, color: Color, stroke: bool) { let s = &mut self.body; let op = |fill: &'static str, line: &'static str| if stroke { line } else { fill }; @@ -1363,7 +1363,7 @@ impl<'a> Writer<'a> { let source = if matches!(color, Color::Rgb { .. }) { ai_ops::as_written(color) } else { color }; let cmyk = self.cms.to_cmyk(&source, self.cms.settings().intent).map(unit); ps::push_nums(s, &cmyk.map(f64::from)); - ps::push_nums(s, &color.to_rgb().map(|v| f64::from(unit(v)))); + ps::push_nums(s, &ai_ops::rgb_values(color).map(f64::from)); let _ = writeln!(s, "{}", op("Xa", "XA")); } } diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index 81857a9501..0a7220faa5 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -371,9 +371,11 @@ impl Writer<'_> { /// A stop's color and its style: gray `lightness 0`, CMYK `c m y k 1`, RGB (and Lab, as RGB) /// `c m y k r g b 2` with its CMYK equivalent; a spot color its full color's CMYK equivalent, /// then its RGB values when it isn't CMYK, its name and how much lighter than the full color, - /// then `3` (CMYK), or `1 4` (RGB, and Lab as RGB). An RGB color's CMYK equivalent is converted - /// from its values as written ([`ai_ops::as_written`]), a Lab color's from its Lab values, and a - /// spot color's is the one the palette writes for it. + /// then `3` (CMYK), or `1 4` (RGB, and Lab as RGB). RGB values are an RGB color's own (in the + /// working RGB space) and a Lab color's display RGB ([`ai_ops::rgb_values`]). An RGB color's CMYK + /// equivalent is converted from its values as written ([`ai_ops::as_written`]), a Lab color's + /// from its Lab values, and a spot color's CMYK equivalent and RGB values both come from its full + /// color as the palette writes it. fn stop_color(&mut self, s: &mut String, stop: &GradientStop) { let doc = self.doc; let intent = self.cms.settings().intent; @@ -382,11 +384,12 @@ impl Writer<'_> { && let Paint::Solid { color: full, swatch: None, .. } = sw.paint { if sw.spot { - let cmyk = self.cms.to_cmyk(&doc.linked_color(ai_ops::as_written(full), true), intent).map(unit); + let full = ai_ops::as_written(full); + let cmyk = self.cms.to_cmyk(&doc.linked_color(full, true), intent).map(unit); ps::push_nums(s, &cmyk.map(f64::from)); let rgb = !matches!(full, Color::Cmyk { .. }); if rgb { - ps::push_nums(s, &full.to_rgb().map(|v| f64::from(unit(v)))); + ps::push_nums(s, &ai_ops::rgb_values(full).map(f64::from)); } s.push_str(&ps::bytes(name.as_bytes())); s.push(' '); @@ -411,7 +414,7 @@ impl Writer<'_> { let source = if matches!(color, Color::Rgb { .. }) { ai_ops::as_written(color) } else { color }; let cmyk = self.cms.to_cmyk(&source, intent).map(unit); ps::push_nums(s, &cmyk.map(f64::from)); - ps::push_nums(s, &color.to_rgb().map(|v| f64::from(unit(v)))); + ps::push_nums(s, &ai_ops::rgb_values(color).map(f64::from)); s.push_str("2 "); } } diff --git a/crates/eps/src/ai_ops.rs b/crates/eps/src/ai_ops.rs index 14b19684f9..3b2bdd88d8 100644 --- a/crates/eps/src/ai_ops.rs +++ b/crates/eps/src/ai_ops.rs @@ -115,6 +115,15 @@ pub(crate) fn as_written(color: Color) -> Color { } } +/// The RGB values written for `color`, clamped to 0..1: an RGB color's own values (in the working +/// RGB space, as a reader reads them back), any other color's display RGB. +pub(crate) fn rgb_values(color: Color) -> [f32; 3] { + match color { + Color::Rgb { r, g, b } => [r, g, b].map(unit), + c => c.to_rgb().map(unit), + } +} + /// The average colour of a gradient's stops (in RGB). pub(crate) fn average(g: &Gradient) -> Color { let n = g.stops.len().max(1) as f32; From cfce3362151e4f9e15d0a00ff4d561d774888cf2 Mon Sep 17 00:00:00 2001 From: bflatastic <bombayapirat@gmail.com> Date: Fri, 13 Nov 2026 00:47:30 +0700 Subject: [PATCH 62/92] Dashes fitted to corners take a subpath with no segments An HP-GL wedge of almost no radius drawn with an adaptive line type (LT-1;EW0.000001,0,0;) leaves a lone moveto. Fitting the dashes to its corners indexed the empty segment list and panicked while rendering. Such a subpath now uses the exact plan, which has no runs to fit. Found by import_fuzz::random_hpgl_instructions_never_panic. --- crates/effects/src/stroke/dash.rs | 4 ++++ crates/effects/src/stroke/tests_fit.rs | 14 ++++++++++++++ crates/engine/tests/import_fuzz.rs | 7 +++++++ 3 files changed, 25 insertions(+) diff --git a/crates/effects/src/stroke/dash.rs b/crates/effects/src/stroke/dash.rs index 400f3b58b9..f174069c4e 100644 --- a/crates/effects/src/stroke/dash.rs +++ b/crates/effects/src/stroke/dash.rs @@ -134,6 +134,10 @@ impl Plan { fn fitted(mut segs: Vec<PathSeg>, mut lens: Vec<f64>, closed: bool, period: f64) -> Self { let n = segs.len(); + // A subpath with no segments (a lone moveto) has no runs to fit. + if n == 0 { + return Self::exact(segs, lens, closed, period); + } // Corners: where segments with length meet at a kink (zero-length ones have no direction). let live: Vec<usize> = (0..n).filter(|&i| lens[i] > EPS).collect(); let mut corners: Vec<usize> = live.windows(2).filter(|w| kink(&segs[w[0]], &segs[w[1]])).map(|w| w[1]).collect(); diff --git a/crates/effects/src/stroke/tests_fit.rs b/crates/effects/src/stroke/tests_fit.rs index b389cab853..cc69606aee 100644 --- a/crates/effects/src/stroke/tests_fit.rs +++ b/crates/effects/src/stroke/tests_fit.rs @@ -37,6 +37,20 @@ fn near(a: f64, b: f64, tol: f64) -> bool { // ---------------------------------------------------------------- dashes fitted to corners +/// A subpath with no segments (a lone moveto, as an HP-GL wedge of almost no radius gives) is +/// dashed fitted to corners without a panic, and the other subpaths keep their dashes. +#[test] +fn fitted_dashes_take_a_subpath_with_no_segments() { + let mut b = BezPath::new(); + b.move_to((5.0, 5.0)); + b.extend(line(100.0)); + let d = dash(&b, &fitted(&[10.0, 5.0], 0.0)).expect("dashed"); + assert!(moves(&d.path) > 1 && length(&d.path) > 0.0, "{:?}", d.path); + let mut lone = BezPath::new(); + lone.move_to((0.0, 0.0)); + assert!(dash(&lone, &fitted(&[10.0, 5.0], 0.0)).is_some_and(|d| d.path.elements().is_empty())); +} + #[test] fn every_corner_of_a_rectangle_falls_mid_dash() { // 100 × 50 with 12/6: the long sides hold 6 periods (scaled by 100/108), the short ones 3 diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 4542496fc5..4688845de4 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2958,6 +2958,13 @@ fn the_hpgl_sample_opens() { assert!(paths > 15, "{paths} paths, warnings {:?}", l.warnings); } +/// A wedge drawn with the adaptive line type `LT-1` and a near-zero radius (a case the fuzz test +/// found) opens and places without a panic. +#[test] +fn an_adaptive_line_type_wedge_of_almost_no_radius_opens() { + survive_metafile("HP-GL tiny adaptive wedge", "x.plt", b"IN;SP1;LT-1;EW0.000001,0,0;").unwrap(); +} + /// A random instruction: a mnemonic the reader plays (or one it doesn't) with hostile numbers. fn arb_hpgl_instruction() -> impl Strategy<Value = String> { let op = prop::sample::select(vec![ From 556123e691bdf02748f270902c616c57481f05b0 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 09:47:53 -0800 Subject: [PATCH 63/92] 3D and Materials: Expand as Wireframes keeps a layer's path guides and cuts Revolve's art for Revolve A layer expanded to its edges keeps the paths made into guides inside it, as it keeps its ruler guides. A clipping group with Revolve and Rotate is cut for Revolve, the effect the canvas draws, so its fills go in one piece and art with more fills than a 3D effect takes still expands. --- crates/effects/src/solids/wireframe.rs | 14 +++++++----- crates/effects/src/solids/wireframe/tests.rs | 21 +++++++++++++++++ crates/engine/src/cmd/wireframe.rs | 24 ++++++++++---------- crates/engine/src/cmd/wireframe/tests.rs | 23 +++++++++++++++++++ docs/mcp.md | 2 +- 5 files changed, 65 insertions(+), 19 deletions(-) diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs index 4f93b2f41a..f1dc324679 100644 --- a/crates/effects/src/solids/wireframe.rs +++ b/crates/effects/src/solids/wireframe.rs @@ -10,7 +10,7 @@ use vectorcraft_three_d::Facet; use super::{ INFLATE, Profile, ROTATE, facets, geometry, has_solid, inflate_options, is_container, is_plane, is_three_d, label_of, members, object_profile, - region, reshape_members, resolve, solid, solid_paints, solid_parts, union_bounds, view_options, + region, reshape_members, resolve, resolve_for, solid, solid_paints, solid_parts, union_bounds, view_options, }; use crate::{GeomContext, is_geometry}; @@ -41,16 +41,18 @@ pub fn three_d_wireframe_in(doc: Option<&Document>, n: &Node) -> Result<Vec<Path // is no surface to expand. let revolve = crate::has_revolve(n) && crate::revolve_art_in(doc, n).is_some(); if !revolve && !(has_solid(n) && super::solid_art_in(doc, n).is_some()) { - let n = &*resolve(doc, n, label)?; - // The builders' own error says why, when there is one. + // The builders' own error says why, when there is one. Revolve's art is cut for Revolve, + // as the canvas cuts it. if crate::has_revolve(n) { - crate::revolve::revolve_face_paths(doc, n)?; + crate::revolve::revolve_face_paths(doc, &*resolve_for(doc, n, label, false)?)?; } else { - solid_face_paths(doc, n)?; + solid_face_paths(doc, &*resolve(doc, n, label)?)?; } return Err(format!("{label} draws the source art for this object instead of its 3D surface")); } - let n = &*resolve(doc, n, label)?; + // Revolve's art is cut for Revolve whatever other 3D effect the object carries, as the canvas + // cuts it ([`crate::revolve_art_in`]). + let n = &*if revolve { resolve_for(doc, n, label, false)? } else { resolve(doc, n, label)? }; let faces = if revolve { crate::revolve::revolve_face_paths(doc, n)? } else { solid_face_paths(doc, n)? }; let faces = after_geometry(doc, n, faces, revolve); let wire = edges(&faces)?; diff --git a/crates/effects/src/solids/wireframe/tests.rs b/crates/effects/src/solids/wireframe/tests.rs index 4af9d7b526..5ea4423ab3 100644 --- a/crates/effects/src/solids/wireframe/tests.rs +++ b/crates/effects/src/solids/wireframe/tests.rs @@ -204,6 +204,27 @@ fn a_geometry_effect_before_the_drawn_3d_effect_is_not_applied_again() { assert!((both.x0 - alone.x0).abs() < 1e-6 && (both.x1 - alone.x1).abs() < 1e-6, "{alone:?} {both:?}"); } +/// A clipping group with Revolve and Rotate draws Revolve, which cuts the art's fills as one piece +/// in the top fill's place. Its wireframe is cut the same way, so art with more fills than a 3D +/// effect takes members still expands, to the edges of Revolve alone. +#[test] +fn a_clipping_group_with_revolve_and_rotate_expands_the_art_revolve_cuts() { + let mut art = Node::path(NodeId(3), square(100.0, 100.0, 100.0), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let fill = art.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Fill(_))).cloned().unwrap(); + art.appearance.items = vec![fill; super::super::MAX_MEMBERS + 4]; + let mut clip = Node::path(NodeId(2), square(100.0, 100.0, 50.0), Appearance::default()); + if let vectorcraft_doc::NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let mut n = Node::new(NodeId(1), vectorcraft_doc::NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true }); + n.appearance.effects.push(crate::new_effect(crate::REVOLVE, &json!({})).unwrap()); + let alone = segments(&three_d_wireframe_in(None, &n).unwrap()); + n.appearance.effects.push(crate::new_effect(ROTATE, &json!({})).unwrap()); + assert!(crate::revolve_art_in(None, &n).is_some(), "the canvas draws Revolve"); + let both = segments(&three_d_wireframe_in(None, &n).unwrap()); + assert_eq!(both, alone); +} + #[test] fn an_object_without_a_visible_3d_effect_is_an_error() { let mut n = solid_square(super::super::EXTRUDE, json!({})); diff --git a/crates/engine/src/cmd/wireframe.rs b/crates/engine/src/cmd/wireframe.rs index ccd82dd0c3..595b837aa5 100644 --- a/crates/engine/src/cmd/wireframe.rs +++ b/crates/engine/src/cmd/wireframe.rs @@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> { "Expand as Wireframes", [], None, - "{ids?: [..] (default: the selected objects)} replace each object that has a visible 3D effect (Extrude & Bevel, Revolve, Inflate, Rotate or one of their 3D (Classic) forms, on the object or on its fills and strokes) with a group of paths: the edges of the faces the effect draws, as the object is turned and projected now, each edge once (edges that meet end to end make one path, closed where it comes back to its start; curved edges stay curves; geometry effects after the 3D effect, on the object or on a fill or stroke, reshape them too), stroked 0.5 pt black with round joins and no fill (VectorCraft's own); the group takes the object's place, id, name, opacity, blending mode, opacity mask, attributes, slice and text wrap and is selected (a layer stays a layer holding the edges, which are selected, and its ruler guides); objects without a visible 3D effect are left as they are; one undo step. An error, with nothing changed, when no object is selected or given, when none has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges → {ids}", + "{ids?: [..] (default: the selected objects)} replace each object that has a visible 3D effect (Extrude & Bevel, Revolve, Inflate, Rotate or one of their 3D (Classic) forms, on the object or on its fills and strokes) with a group of paths: the edges of the faces the effect draws, as the object is turned and projected now, each edge once (edges that meet end to end make one path, closed where it comes back to its start; curved edges stay curves; geometry effects after the 3D effect, on the object or on a fill or stroke, reshape them too), stroked 0.5 pt black with round joins and no fill (VectorCraft's own); the group takes the object's place, id, name, opacity, blending mode, opacity mask, attributes, slice and text wrap and is selected (a layer stays a layer holding the edges, which are selected, and its ruler guides and paths made into guides); objects without a visible 3D effect are left as they are; one undo step. An error, with nothing changed, when no object is selected or given, when none has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges → {ids}", has_doc, expand_as_wireframes )] @@ -38,12 +38,12 @@ fn wire_appearance() -> Appearance { Appearance { items: vec![AppearanceItem::Stroke(stroke)], ..Default::default() } } -/// The ruler guides of layer `n` and then those of the sublayers inside it, in order, added to -/// `out`. A guide from a hidden or locked sublayer, or from one inside a hidden or locked group or -/// sublayer, is hidden or locked itself. -fn ruler_guides(n: &Node, hidden: bool, locked: bool, out: &mut Vec<Arc<Node>>) { +/// The guides (ruler guides and paths made into guides) of layer `n` and then those of the groups +/// and sublayers inside it, in order, added to `out`. A guide from a hidden or locked sublayer, or +/// from one inside a hidden or locked group or sublayer, is hidden or locked itself. +fn guides(n: &Node, hidden: bool, locked: bool, out: &mut Vec<Arc<Node>>) { let Some(children) = n.children() else { return }; - for c in children.iter().filter(|c| c.is_ruler_guide()) { + for c in children.iter().filter(|c| c.is_guide()) { let mut g = c.clone(); if hidden || locked { let g = Arc::make_mut(&mut g); @@ -52,8 +52,8 @@ fn ruler_guides(n: &Node, hidden: bool, locked: bool, out: &mut Vec<Arc<Node>>) } out.push(g); } - for c in children.iter().filter(|c| !c.is_ruler_guide()) { - ruler_guides(c, hidden || !c.visible, locked || c.locked, out); + for c in children.iter().filter(|c| !c.is_guide()) { + guides(c, hidden || !c.visible, locked || c.locked, out); } } @@ -89,13 +89,13 @@ fn expand_as_wireframes(s: &mut Session, p: &Value) -> Result<Value> { let Some(source) = d.node(id) else { continue }; if matches!(source.kind, NodeKind::Layer { .. }) { // A layer stays a layer: its members become the edges, and its 3D effect is removed. - // Its ruler guides and those of its sublayers are not art: they stay, after the edges. + // Its guides and those of its sublayers are not art: they stay, after the edges. let mut layer = source.clone(); - let mut guides = vec![]; - ruler_guides(source, false, false, &mut guides); + let mut kept = vec![]; + guides(source, false, false, &mut kept); let mut children: Vec<Arc<Node>> = wire.into_iter().map(|path| Arc::new(Node::path(d.alloc_id(), path, wire_appearance()))).collect(); selected.extend(children.iter().map(|c| c.id)); - children.extend(guides); + children.extend(kept); if let Some(members) = layer.children_mut() { *members = children; } diff --git a/crates/engine/src/cmd/wireframe/tests.rs b/crates/engine/src/cmd/wireframe/tests.rs index 268fbcd6db..33a30975d9 100644 --- a/crates/engine/src/cmd/wireframe/tests.rs +++ b/crates/engine/src/cmd/wireframe/tests.rs @@ -112,6 +112,29 @@ fn a_layer_keeps_its_ruler_guides() { assert!(!st.selection.objects.is_empty() && st.selection.objects.iter().all(|id| !before.contains(id))); } +/// A path made into a guide is not art either: it stays on the layer, after the edges, and is not +/// selected. +#[test] +fn a_layer_keeps_its_path_guides() { + let (mut s, id) = setup(None); + let layer = s.doc().unwrap().doc.layers[0].id; + s.execute("path.create", &json!({"d":"M0 300L300 300"})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + let guides = |s: &Session| -> Vec<NodeId> { + s.doc().unwrap().doc.node(layer).unwrap().children().unwrap().iter().filter(|c| c.is_guide()).map(|c| c.id).collect() + }; + let before = guides(&s); + assert_eq!(before.len(), 1); + assert_ne!(before[0], id); + s.execute("effect.apply", &json!({"ids":[layer.0],"effect":"threeD.extrude","item":null,"params":{}})).unwrap(); + run(&mut s, json!({"ids":[layer.0]})).unwrap(); + assert_eq!(guides(&s), before); + let st = s.doc().unwrap(); + let children = st.doc.node(layer).unwrap().children().unwrap(); + assert!(children.len() > 1 && children.last().is_some_and(|c| c.is_guide())); + assert!(!st.selection.objects.is_empty() && st.selection.objects.iter().all(|i| !before.contains(i))); +} + /// The number of segments in the wireframe group `id`. fn segments(s: &Session, id: NodeId) -> usize { let st = s.doc().unwrap(); diff --git a/docs/mcp.md b/docs/mcp.md index 7457e6faa3..7d344068bb 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3237,7 +3237,7 @@ on the object or on a fill or stroke, reshape its edges as they reshape the draw black stroke with round joins and no fill (VectorCraft's own choice). The group keeps the object's place, id, name, visibility, lock, opacity, blending mode, knockout, opacity mask, attributes, slice and text wrap, and the groups are selected. -A layer in `ids` stays a layer: its members are replaced by the edges, which are selected, and its ruler guides stay. Objects without a visible 3D effect do not change. It is one undo step and +A layer in `ids` stays a layer: its members are replaced by the edges, which are selected, and its ruler guides and paths made into guides stay. Objects without a visible 3D effect do not change. It is one undo step and returns `{ids}`, the groups. It is an error, and nothing changes, when nothing is selected or given, when no object has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges. From da39713031860fee7e9efd7d4658c544f08c8ef4 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 10:06:56 -0800 Subject: [PATCH 64/92] CSS: css.export refuses a scope or path that is not a string css.export read `scope` and `path` with a lenient string lookup, so a value of the wrong type (`5`, `true`, `["all"]`, `null`) was treated as left out: the selection was exported, or the CSS came back as data instead of being written. Both keys are now checked before anything is generated or written, and a supplied value that is not a string is an error ("`scope` must be a string", "`path` must be a string"). A left-out scope still exports the selection, and a left-out path still returns the CSS. css.generate and css.export with scope "all" do not use `ids` or `id`, but a malformed one is now an error there too, as it already is for the selection. --- crates/engine/src/cmd/css.rs | 30 ++++++++++++---- crates/engine/src/tests/css.rs | 63 ++++++++++++++++++++++++++++++++++ docs/mcp.md | 8 +++-- docs/target-app-parity.md | 1 + 4 files changed, 93 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/css.rs b/crates/engine/src/cmd/css.rs index ed83a01410..dd6af8e6eb 100644 --- a/crates/engine/src/cmd/css.rs +++ b/crates/engine/src/cmd/css.rs @@ -8,7 +8,7 @@ use vectorcraft_doc::NodeId; use vectorcraft_svg::{CssOptions, CssSheet, css_rules}; use super::fileio::{default_name, encode_with_warnings, isolated, write_file}; -use super::{CommandSpec, Result, Session, bad, cmd, has_doc, str_param, targets}; +use super::{CommandSpec, Result, Session, bad, cmd, has_doc, named_ids, targets}; pub fn specs() -> Vec<CommandSpec> { vec![ @@ -21,13 +21,13 @@ pub fn specs() -> Vec<CommandSpec> { has_doc, selection ), - cmd!(query "css.generate", "Generate CSS", [], None, "{…options as css.selection} css.selection of every object in the document", has_doc, generate), + cmd!(query "css.generate", "Generate CSS", [], None, "{…options as css.selection} css.selection of every object in the document; ids and id are not used, and a malformed one is an error", has_doc, generate), cmd!( "css.export", "Export CSS", [], None, - "{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of what the object draws, with the clips, masks and effects of the groups and layers that hold it, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once", + "{path?: string, scope?: selection|all (default selection when left out; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} a path or scope given as anything but a string, null included, is an error, and so is a malformed ids or id with scope all, which does not use them; write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of what the object draws, with the clips, masks and effects of the groups and layers that hold it, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once", has_doc, export ), @@ -77,16 +77,34 @@ fn selection(s: &mut Session, p: &Value) -> Result<Value> { Ok(result(&sheet(s, p, ids, "css.selection")?)) } +/// Checks the form of the `ids` or `id` that `p` gives a command that exports every object and does +/// not use them: a malformed one is an error, as it is for the selection. +fn unused_ids(p: &Value) -> Result<()> { + named_ids(p).map(|_| ()) +} + fn generate(s: &mut Session, p: &Value) -> Result<Value> { + unused_ids(p)?; let ids = everything(s)?; Ok(result(&sheet(s, p, ids, "css.generate")?)) } +/// The string `p` gives for `key`, or `None` when `p` leaves it out. Any value given that is not a +/// string, null included, is an error: "`{key}` must be a string". +fn given_text<'a>(p: &'a Value, key: &str, cmd: &str) -> Result<Option<&'a str>> { + p.get(key).map(|v| v.as_str().ok_or_else(|| bad(cmd, format!("`{key}` must be a string")))).transpose() +} + fn export(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "css.export"; - let ids = match str_param(p, "scope").unwrap_or("selection") { + let scope = given_text(p, "scope", C)?; + let path = given_text(p, "path", C)?; + let ids = match scope.unwrap_or("selection") { "selection" => targets(s, p)?, - "all" => everything(s)?, + "all" => { + unused_ids(p)?; + everything(s)? + } other => return Err(bad(C, format!("scope must be selection or all (got {other})"))), }; let sheet = sheet(s, p, ids, C)?; @@ -115,7 +133,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> { } } let n = sheet.rules.len(); - match str_param(p, "path") { + match path { Some(path) => { write_file(path, css.as_bytes())?; let folder = std::path::Path::new(path).parent(); diff --git a/crates/engine/src/tests/css.rs b/crates/engine/src/tests/css.rs index 764990b100..1ce5b09e41 100644 --- a/crates/engine/src/tests/css.rs +++ b/crates/engine/src/tests/css.rs @@ -98,6 +98,69 @@ fn export_writes_the_sheet_and_rasterized_art() { assert!(s.execute("css.export", &json!({})).is_err(), "nothing selected: nothing to export"); } +#[test] +fn export_refuses_a_scope_or_path_that_is_not_a_string() { + let mut s = session(); + run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40})); + run(&mut s, "shape.ellipse", json!({"x": 60, "y": 0, "width": 40, "height": 40})); + let refused = |s: &mut Session, p: Value, key: &str| { + let e = s.execute("css.export", &p).expect_err(&p.to_string()).to_string(); + assert_eq!(e, format!("invalid parameters for `css.export`: `{key}` must be a string"), "{p}"); + }; + for scope in [json!(5), json!(true), json!(["all"]), Value::Null] { + refused(&mut s, json!({"scope": scope}), "scope"); + } + for path in [json!(5), Value::Null] { + refused(&mut s, json!({"path": path}), "path"); + refused(&mut s, json!({"path": path, "scope": "all"}), "path"); + } + // Left out, each keeps its default: the selection (the ellipse), and the CSS as data. + let r = run(&mut s, "css.export", json!({})); + assert_eq!((r["rules"].clone(), r["data"].is_string()), (json!(1), true), "{r}"); + assert_eq!(run(&mut s, "css.export", json!({"scope": "all"}))["rules"], json!(2)); + let e = s.execute("css.export", &json!({"scope": "page"})).unwrap_err().to_string(); + assert!(e.contains("scope must be selection or all (got page)"), "{e}"); +} + +#[test] +fn every_object_refuses_malformed_ids_it_does_not_use() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40}))["id"].as_u64().unwrap(); + run(&mut s, "shape.ellipse", json!({"x": 60, "y": 0, "width": 40, "height": 40})); + for (cmd, base) in [("css.generate", json!({})), ("css.export", json!({"scope": "all"}))] { + for (key, value, says) in [ + ("ids", json!("abc"), "`ids` must be an array of object ids, not \"abc\""), + ("ids", json!([1.5]), "object ids are non-negative integers, not 1.5"), + ("id", json!(-1), "object ids are non-negative integers, not -1"), + ("id", json!("$step"), "`id` is the step reference \"$step\""), + ] { + let mut p = base.clone(); + p[key] = value; + let e = s.execute(cmd, &p).expect_err(&format!("{cmd} {p}")).to_string(); + assert!(e.contains(says), "{cmd} {p}: {e}"); + } + // Well-formed ids are still not used: every object. + let mut p = base.clone(); + p["ids"] = json!([rect]); + let rules = run(&mut s, cmd, p)["rules"].clone(); + let count = rules.as_array().map_or(rules.as_u64(), |r| Some(r.len() as u64)); + assert_eq!(count, Some(2), "{cmd}: {rules}"); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn export_with_a_scope_that_is_not_a_string_writes_nothing() { + let mut s = session(); + run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40})); + let dir = std::env::temp_dir().join(format!("vc-css-scope-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("site.css"); + assert!(s.execute("css.export", &json!({"path": path.to_string_lossy(), "scope": 5})).is_err()); + assert!(!path.exists(), "no file for a refused scope"); + std::fs::remove_dir_all(&dir).ok(); +} + #[cfg(not(target_arch = "wasm32"))] #[test] fn export_to_a_path_puts_the_pictures_next_to_the_sheet() { diff --git a/docs/mcp.md b/docs/mcp.md index 62a4212472..00b92b30ca 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6851,9 +6851,11 @@ says when CSS can't describe the art exactly (`unsupported`: other shapes, image written as its box, or with `rasterize: true` as `background-image: url(<class>.png)`. `units` is `px` (1 px per point, as in SVG export), `pt`, `mm`, `cm` or `in`; `unnamed: false` leaves out unnamed objects (counted in `skipped`). `css.generate` does the same for the whole document, and `css.export {path?, scope?: selection|all}` -writes the `.css` file with the PNGs of rasterized art next to it (no path: `data` and `images` as base64). Its -`warnings` add the PNGs' notes to the CSS's, each once (an image that couldn't be decoded, type written in the -fallback font…). In the app, `css.copy` copies the CSS and `css.exportFile` writes it through a save panel (the web +writes the `.css` file with the PNGs of rasterized art next to it (no path: `data` and `images` as base64). A left-out +`scope` exports the selection; a `path` or `scope` given as anything but a string, `null` included, is an error, and +nothing is written. `css.generate` and `css.export {scope: "all"}` do not use `ids` or `id`, but a malformed one is still +an error. Its `warnings` add the PNGs' notes to the CSS's, each once (an image that couldn't be decoded, +type written in the fallback font…). In the app, `css.copy` copies the CSS and `css.exportFile` writes it through a save panel (the web downloads it) and shows the first note in the status bar, with how many there are; resting the pointer on the message lists up to 30 of them, fewer when the list would not fit in the window, with how many were left out. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5ec97e34c1..076b0976da 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -957,6 +957,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The command palette lists `select.key` as Cancel Key Object, and a pick cancels the key object (#541). Agent selection commands validate every object id before changing the selection and report the resulting count and ids (#784). Commands that act on `id` or else the selection, the highlighted layer row or a new object (`css.export`, `object.isolate`, `layer.locate`, `layer.releaseToLayers`, the graphic, character and paragraph style commands, `image.info`, the `links.*` and `shaper.*` commands that take an `id`, opacity mask editing and viewing, `symbol.edit`, the blend spine commands and `path.curvature`) refuse an `id` that is not an object id (`"abc"`, `"5"`, `true`) and change nothing; `effect.apply` with the effect given as `id` still applies it to the selection. + `css.export` refuses a `scope` or `path` that is not a string (`5`, `true`, `["all"]`, `null`) before writing anything, instead of exporting the selection or returning the CSS as data; a left-out `scope` still exports the selection, and a left-out `path` still returns the CSS. `css.generate` and `css.export {scope: "all"}` refuse a malformed `ids` or `id`, which they do not use, as the selection's export does. Agents read the key object: `select.key` returns it, and `document.inspect` and the `ui.pointer` and `pointer_gesture` replies report it as `key`. `select.key` with an id that isn't a selected top-level object returns an error and leaves the key unchanged. The Select menu's Same commands, its Object commands that select art, Next Object Above and Below, saved selections and the Magic Wand (#2655) select only objects that Select All selects and objects inside them: nothing hidden or locked, nothing in a hidden or locked group, layer or sublayer, nothing on a template layer, and in isolation mode nothing outside the isolated group or layer. In isolation mode Select All, All on Active Artboard and Inverse select nothing while the isolated group or layer, or a group or layer it is in, is hidden, locked or a template layer. Select ▸ Object ▸ Bristle Brush Strokes selects the objects whose topmost stroke has a bristle brush, whatever the brush is named, and leaves out objects whose brush is of another type, also when its name has "Bristle" in it. From 70a4c44033149f0b1e445606513cb947d6874d77 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 10:08:53 -0800 Subject: [PATCH 65/92] Effects: every fill a painted effect returns follows the Transform effect A painted live effect on a fill can return a piece with several fills, each a copy of that fill with its effects. A Transform effect after it moved the piece, but only the first returned fill's raster effects followed it: with a Drop Shadow before the painted effect and a 200% Transform effect after it, the first fill's shadow offset became 20 pt and the second fill's stayed 10 pt. Each returned fill and stroke now takes the followed values. --- crates/effects/src/reshape.rs | 25 ++++++++++++++----------- crates/engine/src/tests/plugins.rs | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index e1e99b1a80..58f083ef68 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -1815,22 +1815,25 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), h }); } -/// Make the raster effects of `leaf`'s first fill or stroke from index `from` on follow Transform -/// effect `t`, listed after them, once `t` has moved the leaf ([`crate::raster::follow_one`]). -/// `was` holds those effects as they were before `t` was applied: each one that follows takes the -/// value [`crate::raster::followed`] gives it, in place of what Scale Strokes & Effects made of it, -/// and the others keep theirs. +/// Make the raster effects of each of `leaf`'s fills and strokes from index `from` on follow +/// Transform effect `t`, listed after them, once `t` has moved the leaf +/// ([`crate::raster::follow_one`]). The fills and strokes are copies of one fill or stroke (those a +/// painted plug-in returns, [`vectorcraft_plugins::painted::items`]), with the same effects. `was` +/// holds those effects of the first one as they were before `t` was applied: each one that +/// follows takes the value [`crate::raster::followed`] gives it, in place of what Scale Strokes & +/// Effects made of it, and the others keep theirs. fn follow_piece(leaf: &mut Node, was: &[Effect], from: usize, t: &Effect) { let mut follow = was.to_vec(); if !crate::raster::follow_one(&mut follow, t) { return; } - let Some(item) = leaf.appearance.items.first_mut() else { return }; - for (k, (new, old)) in follow.into_iter().zip(was).enumerate() { - if new != *old - && let Some(e) = item.effects_mut().get_mut(from + k) - { - *e = new; + for item in &mut leaf.appearance.items { + for (k, (new, old)) in follow.iter().zip(was).enumerate() { + if new != old + && let Some(e) = item.effects_mut().get_mut(from + k) + { + *e = new.clone(); + } } } } diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 8d1570e996..be4193a812 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -452,6 +452,30 @@ fn a_painted_effect_on_a_fill_and_refused_results() { } } +/// A fill's Drop Shadow before a painted effect that returns two fills, and a 200% Transform effect +/// after it: the shadow of each returned fill follows the Transform effect. The square the effect +/// returns spans 30 to 70 pt once scaled, so both half-opaque shadows, 20 pt to the right, cover +/// 85 pt as they cover 75 pt. +#[test] +fn a_shadow_on_each_fill_a_painted_effect_returns_follows_the_transform_after_it() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let out = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00"), solid("#0000ff")]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.twofills", true), &out)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for (effect, params) in [ + ("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 50})), + ("plugin.test.engine.twofills", json!({})), + ("distort.transform", json!({"scaleH": 200, "scaleV": 200})), + ] { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "params": params, "item": fill})); + } + let img = drawn(&s); + let (near, far) = (img.pixel(75, 50), img.pixel(85, 50)); + assert_ne!(near, WHITE, "the shadows reach past the square"); + assert_eq!(far, near, "both shadows at 20 pt"); +} + /// A result refused for a swatch the document doesn't have stays cached: once the document has the /// swatch, the next check of that result clears the error. #[test] From 1132cf0c79524bacd953e70712898ea38ca2fa49 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 10:18:21 -0800 Subject: [PATCH 66/92] SVG: Save As SVG can include the document's variables and data sets SVG Options gets an Include Variable Data checkbox (the `variableData` option, off by default). With it on, the saved SVG carries the document's text variables, its data sets' text values and each text variable's bound type object as a `variableSets` element, in the layout Load Variable Library already reads. A bound object is named by its element id in a `docRef="id('...')"` attribute; an object without an id gets one. Opening such an SVG reads the variable data back: the text variables, the data sets (none active) and the bindings, so choosing a data set changes the type again. Variable data that Load Variable Library would refuse is left out with a warning, and the art opens. Markup that only looks like the element (in comments, CDATA sections, processing instructions or the document type declaration) is skipped. Variables of other kinds, names XML can't hold, more than 256 text variables or 1,000 data sets, and variable data over 8 MiB or 2,000,000 XML nodes are left out of the file, each with a warning. Type written as outlines opens as art, so its variables are written unbound, with a warning. --- crates/engine/src/cmd/fileio/commands.rs | 4 +- crates/engine/src/cmd/fileio/load.rs | 31 ++- crates/engine/src/cmd/fileio/svg.rs | 6 + crates/engine/src/cmd/variablecmds.rs | 1 + crates/engine/src/cmd/variablecmds/library.rs | 178 +++++++++++++- crates/engine/src/tests/variables.rs | 188 +++++++++++++++ crates/svg/src/export.rs | 9 + crates/svg/src/export/output.rs | 5 +- crates/svg/src/export/text.rs | 1 + crates/svg/src/export/variables.rs | 223 ++++++++++++++++++ crates/svg/src/import.rs | 19 +- crates/svg/src/import/importer.rs | 8 +- crates/svg/src/lib.rs | 14 ++ crates/ui-egui/src/dialogs/svg_options.rs | 21 ++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/file-format-parity.md | 2 +- docs/mcp.md | 15 +- 43 files changed, 735 insertions(+), 17 deletions(-) create mode 100644 crates/svg/src/export/variables.rs diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 7f95b8e203..fe7c9d8f3e 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<CommandSpec> { "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; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), 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, swatches, brushes, symbols, variables and data sets, and data sets' text values; 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), importer?: \"plugin.<id>\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> 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; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), 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, swatches, brushes, symbols, variables and data sets, and data sets' text values; 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), importer?: \"plugin.<id>\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> 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). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", always, load::open ), @@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts, variableData} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", has_doc, export::export ), diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index ec1d7e36ba..052b1683d8 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -398,7 +398,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> let editing = vectorcraft_svg::editing(&text).map(|e| (e.intact, move || vectorcraft_format::base64_decode(&e.data))); // Relative links are found in the SVG's folder (when `name` is a path). let folder = std::path::Path::new(name).parent().map(|f| f.to_string_lossy()).filter(|f| !f.is_empty()).map(|f| absolute_path(&f)); - restore_or_import(editing, || import_svg(&text, folder.as_deref()))? + restore_or_import(editing, || open_svg(&text, folder.as_deref()))? } "pdf" | "ai" | "ait" => { // The file is read and checked once, for the document it carries, its pages, the @@ -573,6 +573,33 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> /// own): rasters stay linked, SVG files become art, missing ones placeholders (see /// [`vectorcraft_svg::ImportOptions`]). pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document, Vec<String>)> { + import_svg_finding(text, folder, &std::collections::HashSet::new()).map(|(doc, warnings, _)| (doc, warnings)) +} + +/// [`import_svg`] for a document opened from an SVG: with the variable data the file carries (SVG +/// Options › Include Variable Data), read as [`crate::cmd::variablecmds::svg_library`] says. Variable +/// data that can't be read is left out with a warning; the art opens either way. +fn open_svg(text: &str, folder: Option<&str>) -> Result<(Document, Vec<String>)> { + let lib = match crate::cmd::variablecmds::svg_library(text) { + None => return import_svg(text, folder), + Some(Err(e)) => { + let (doc, mut warnings) = import_svg(text, folder)?; + warnings.push(format!("the file's variable data wasn't read: {e}")); + return Ok((doc, warnings)); + } + Some(Ok(lib)) => lib, + }; + let (mut doc, mut warnings, found) = import_svg_finding(text, folder, &lib.elements())?; + warnings.extend(crate::cmd::variablecmds::give_svg_library(&mut doc, lib, &found)); + Ok((doc, warnings)) +} + +/// [`import_svg`], also returning the object made of each element whose id `ids` lists. +fn import_svg_finding( + text: &str, + folder: Option<&str>, + ids: &std::collections::HashSet<String>, +) -> Result<(Document, Vec<String>, vectorcraft_svg::Found)> { // The importer reads on a thread of its own (for its stack): what may be read is what may be // read here. let scope = crate::file_access::ScopeHandle::capture(); @@ -588,7 +615,7 @@ pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document, Some((bytes, link)) }) }; - vectorcraft_svg::import_with(text, &vectorcraft_svg::ImportOptions { folder, read: Some(&read) }).map_err(err) + vectorcraft_svg::import_finding(text, &vectorcraft_svg::ImportOptions { folder, read: Some(&read) }, ids).map_err(err) } /// Open a file's bytes as the new active document (what `document.open` does) → diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index 3123fdcfb1..eebac553d3 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -90,6 +90,12 @@ pub const OPTIONS: &[FormatOption] = &[ default: "false", description: "embed the fonts type uses as @font-face, subset to the characters used (whole when the font's licence forbids subsetting, left out with a warning when it forbids embedding)", }, + FormatOption { + name: "variableData", + ty: "boolean", + default: "false", + description: "Include Variable Data: write the text variables, the data sets and the type object each text variable is bound to as a variable library (variableSets) that document.open and variable.loadLibrary read back; variables of other kinds, and what is past the limits variable.loadLibrary reads (256 text variables, 1,000 data sets, 8 MiB), are left out with a warning", + }, FormatOption { name: "svg", ty: "object", diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index ad70f047bc..241487112b 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -27,6 +27,7 @@ mod csv; mod library; use csv::{MAX_GRAPH_CSV, dataset_export, dataset_import}; +pub(crate) use library::{give_svg_library, svg_library}; use library::{load_library, save_library}; /// The longest variable or dataset name, in characters (names arrive from MCP and files). diff --git a/crates/engine/src/cmd/variablecmds/library.rs b/crates/engine/src/cmd/variablecmds/library.rs index 54f3689e99..e1f4ae0bd5 100644 --- a/crates/engine/src/cmd/variablecmds/library.rs +++ b/crates/engine/src/cmd/variablecmds/library.rs @@ -1,6 +1,8 @@ +use std::collections::HashMap; + use super::{ - BTreeMap, BTreeSet, DataSet, DataValue, EngineError, MAX_NAME, Result, Session, Value, Variable, VariableKind, absolute_path, bad, json, named, - read_file_head, str_param, variables_unlocked, write_file, + BTreeMap, BTreeSet, DataSet, DataValue, Document, EngineError, MAX_NAME, NodeId, NodeKind, Result, Session, Value, Variable, VariableKind, + Variables, absolute_path, bad, json, named, read_file_head, str_param, variables_unlocked, write_file, }; /// The namespace of a variable library's elements, the default namespace of its data sets' values, @@ -169,12 +171,20 @@ fn without_doctype(xml: &str, cmd: &str) -> Result<String> { } /// A variable library as Load Variable Library reads it: the names of its text variables in the -/// order it declares them, the name and `trait` of each variable of another kind, and its data -/// sets with their text values. -struct Library { +/// order it declares them, the name and `trait` of each variable of another kind, its data sets +/// with their text values, and the element id each text variable's `docRef` names. +pub(crate) struct Library { text: Vec<String>, other: Vec<(String, String)>, datasets: Vec<DataSet>, + refs: Vec<(String, String)>, +} + +impl Library { + /// The element ids the text variables' `docRef`s name. + pub(crate) fn elements(&self) -> std::collections::HashSet<String> { + self.refs.iter().map(|(_, e)| e.clone()).collect() + } } /// Read a variable library: XML whose `variableSets` element holds `variableSet` elements, each @@ -200,7 +210,7 @@ fn parse_library(xml: &str, cmd: &str) -> Result<Library> { return Err(bad(cmd, "the file holds no `variableSets` element")); }; let sets: Vec<roxmltree::Node> = root.children().filter(|n| is(n, "variableSet")).collect(); - let (mut text, mut other, mut declared) = (Vec::new(), Vec::new(), BTreeSet::new()); + let (mut text, mut other, mut declared, mut refs) = (Vec::new(), Vec::new(), BTreeSet::new(), Vec::new()); for var in sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "variables")).flat_map(|v| v.children()).filter(|n| is(n, "variable")) { let name = named(var.attribute("varName"), "varName", cmd)?; if !declared.insert(name.clone()) { @@ -210,7 +220,14 @@ fn parse_library(xml: &str, cmd: &str) -> Result<Library> { return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_VARIABLES} variables"))); } match var.attribute("trait") { - Some("textcontent") => text.push(name), + Some("textcontent") => { + // `docRef="id('…')"` names the element of the variable's bound object. + let element = var.attribute("docRef").and_then(|r| r.trim().strip_prefix("id('")?.strip_suffix("')")).filter(|e| !e.is_empty()); + if let Some(element) = element { + refs.push((name.clone(), element.to_string())); + } + text.push(name); + } Some(t) => other.push((name, t.chars().take(MAX_NAME).collect())), None => return Err(bad(cmd, format!("variable `{name}` has no `trait`"))), } @@ -253,7 +270,152 @@ fn parse_library(xml: &str, cmd: &str) -> Result<Library> { } datasets.push(DataSet { name, values }); } - Ok(Library { text, other, datasets }) + Ok(Library { text, other, datasets, refs }) +} + +/// The variable library an SVG carries (SVG Options › Include Variable Data), read as +/// [`parse_library`] reads one, or `None` when the SVG holds no `variableSets` element. The +/// element is read on its own, from its start tag to the end tag that closes it +/// ([`variable_sets_range`]), with each reference to an +/// entity whose name starts with `ns_` replaced by the value the text before the element declares +/// for it (up to [`MAX_LIBRARY_ENTITIES`] of them, plain text of at most [`MAX_NAME`] bytes), as +/// files name the library's namespaces with such entities. The `ns_vars`, `ns_custom` and +/// `ns_flows` entities take the library's namespaces when the file doesn't declare them. An +/// element larger than [`MAX_LIBRARY_BYTES`], before or after the replacements, is refused. +pub(crate) fn svg_library(svg: &str) -> Option<Result<Library>> { + const C: &str = "document.open"; + let (start, end) = variable_sets_range(svg)?; + let read = || { + let end = end.ok_or_else(|| bad(C, "the variable library declares no variables"))?; + let element = svg.get(start..end).unwrap_or_default(); + if element.len() > MAX_LIBRARY_BYTES { + return Err(bad(C, format!("the variable library is larger than {MAX_LIBRARY_BYTES} bytes"))); + } + let mut entities: Vec<(String, String)> = Vec::new(); + for decl in svg.get(..start).unwrap_or_default().split("<!ENTITY").skip(1).take(MAX_LIBRARY_ENTITIES) { + let decl = decl.trim_start(); + let Some((name, rest)) = decl.split_once(|c: char| c.is_ascii_whitespace()) else { continue }; + let rest = rest.trim_start(); + let Some(quote) = rest.chars().next().filter(|q| matches!(q, '"' | '\'')) else { continue }; + let Some(value) = rest.get(1..).and_then(|v| v.split(quote).next()) else { continue }; + if name.starts_with("ns_") && name.len() <= MAX_NAME && value.len() <= MAX_NAME && !value.contains(['<', '&', '%', '"']) { + entities.push((format!("&{name};"), value.to_string())); + } + } + for (name, value) in [("&ns_vars;", NS_VARIABLES), ("&ns_custom;", NS_CUSTOM), ("&ns_flows;", NS_FLOWS)] { + if !entities.iter().any(|(n, _)| n == name) { + entities.push((name.to_string(), value.to_string())); + } + } + // Counted before anything is replaced, so a hostile file can't make a large text first. + let grown = + entities.iter().map(|(name, value)| element.matches(name.as_str()).count() * value.len().saturating_sub(name.len())).sum::<usize>(); + if element.len() + grown > MAX_LIBRARY_BYTES { + return Err(bad(C, format!("the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes"))); + } + let mut element = element.to_string(); + for (name, value) in &entities { + element = element.replace(name.as_str(), value); + } + parse_library(&format!("<svg>{element}</svg>"), C) + }; + Some(read()) +} + +/// Where the first `variableSets` element of `svg` that is markup starts, and where the end tag +/// that closes it ends (`None` when no end tag does), or `None` when there is no such element. +/// Comments, CDATA sections, processing instructions and the document type declaration are +/// skipped, so text inside them that looks like the element is not taken for it. +fn variable_sets_range(svg: &str) -> Option<(usize, Option<usize>)> { + const NAME: &str = "variableSets"; + // The element's name ends at white space, `>` or `/`. + let named = |rest: &str| rest.strip_prefix(NAME).is_some_and(|r| r.starts_with(|c: char| c.is_ascii_whitespace() || matches!(c, '>' | '/'))); + if !svg.contains("<variableSets") { + return None; + } + let (mut at, mut start, mut depth) = (0, None, 0usize); + while let Some(off) = svg.get(at..).and_then(|s| s.find('<')) { + at += off; + let rest = svg.get(at..).unwrap_or_default(); + // The end of a construct that holds text, or of the tag at `at`. + let past = |close: &str| rest.find(close).map(|e| at + e + close.len()); + let next = if rest.starts_with("<!--") { + past("-->") + } else if rest.starts_with("<![CDATA[") { + past("]]>") + } else if rest.starts_with("<?") { + past("?>") + } else if rest.starts_with("<!DOCTYPE") { + // An internal subset ends at `]`, then the declaration at `>`. + match (rest.find('['), rest.find('>')) { + (Some(open), Some(gt)) if open < gt => { + rest.get(open..).and_then(|s| s.find(']')).and_then(|c| rest.get(open + c..)?.find('>').map(|g| at + open + c + g + 1)) + } + (_, gt) => gt.map(|g| at + g + 1), + } + } else if let Some(close) = rest.strip_prefix("</") { + let tag_end = past(">"); + if let Some(s) = start + && named(close) + { + depth = depth.saturating_sub(1); + if depth == 0 { + return Some((s, tag_end)); + } + } + tag_end + } else { + let tag_end = past(">"); + if named(rest.get(1..).unwrap_or_default()) { + let empty = tag_end.and_then(|e| svg.get(..e)).is_some_and(|t| t.ends_with("/>")); + if start.is_none() { + start = Some(at); + } + if empty && depth == 0 { + return Some((at, tag_end)); + } + depth += usize::from(!empty); + } + tag_end + }; + let Some(next) = next else { break }; + at = next; + } + start.map(|s| (s, None)) +} + +/// Give `doc`, opened from an SVG, the variable data `lib` it carries ([`svg_library`]): the +/// library's text variables, its data sets, no active data set, and each text variable bound to +/// the type object made of the element its `docRef` names, or the one type object among the +/// objects a group made of it holds (`found`: the object made of each element, by id). A `docRef` +/// that names no type object, or one another variable is bound to already, binds nothing. The art +/// stays as the file draws it. The warning about the variables of other kinds that were left out, +/// if any. +pub(crate) fn give_svg_library(doc: &mut Document, lib: Library, found: &HashMap<String, NodeId>) -> Option<String> { + let mut vars = + Variables { variables: lib.text.iter().map(|n| Variable { name: n.clone(), kind: VariableKind::Text }).collect(), ..Variables::default() }; + vars.datasets = lib.datasets; + // A type object written with a paint of its own, or with fills and strokes of its appearance, + // is a group around its text (and the appearance's paths). + let text_of = |id: NodeId| match doc.node(id).map(|n| &n.kind) { + Some(NodeKind::Text(_)) => Some(id), + Some(NodeKind::Group { children, .. }) => { + let mut texts = children.iter().filter(|c| matches!(c.kind, NodeKind::Text(_))); + match (texts.next(), texts.next()) { + (Some(one), None) => Some(one.id), + _ => None, + } + } + _ => None, + }; + for (name, element) in &lib.refs { + let Some(id) = found.get(element).and_then(|id| text_of(*id)) else { continue }; + if !vars.bindings.contains_key(&id) { + vars.bind(id, name); + } + } + doc.variables = vars.into(); + left_out_warning(lib.other.iter().map(|(_, t)| t.as_str())) } /// Load Variable Library: replace the document's variables and data sets with a variable library's diff --git a/crates/engine/src/tests/variables.rs b/crates/engine/src/tests/variables.rs index 4b773c889c..08ba426ef1 100644 --- a/crates/engine/src/tests/variables.rs +++ b/crates/engine/src/tests/variables.rs @@ -2916,3 +2916,191 @@ fn applying_a_graph_row_again_records_no_undo_step() { s.execute("dataset.select", &json!({"name": "B"})).unwrap(); assert_eq!(super::history(&s), (before.0 + 1, 0, true)); } + +/// The document's SVG with the options `svg` → (its text, the export's warnings). +fn svg_export(s: &mut Session, svg: Value) -> (String, Vec<Value>) { + let r = s.execute("document.export", &json!({"format": "svg", "svg": svg})).unwrap(); + let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + (String::from_utf8(bytes).unwrap(), r["warnings"].as_array().cloned().unwrap_or_default()) +} + +/// A new session with `svg` opened as `name` → (the session, the open's warnings). +fn svg_opened(svg: &str, name: &str) -> (Session, Vec<Value>) { + let mut o = Session::new(); + let r = o.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap(); + let warnings = r["warnings"].as_array().cloned().unwrap_or_default(); + (o, warnings) +} + +/// Include Variable Data writes the text variables, the data sets' text values and each text +/// variable's bound type object into the SVG, and opening the SVG reads them back: the type +/// objects are bound again, so a data set changes them. Variables of other kinds are left out with +/// a warning. Unnamed objects get ids with Minimal ids, minified files, unique ids and style +/// entities alike, and Load Variable Library reads the same file. +#[test] +fn an_svg_with_variable_data_opens_with_its_variables_data_sets_and_bindings() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let u = text(&mut s, "9.99"); + // A fill of its appearance writes the type in a group with the fill's path. + s.execute("appearance.addFill", &json!({"ids": [u.0]})).unwrap(); + let r = rect(&mut s); + for (name, kind) in [("Name", "text"), ("Shown", "visibility"), ("Price", "text")] { + s.execute("variable.define", &json!({"name": name, "kind": kind})).unwrap(); + } + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Price", "ids": [u.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Shown", "ids": [r.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "A & <B>", "values": {"Name": "Bob", "Price": "1.00\nper pound & tax\r", "Shown": false}})).unwrap(); + s.execute("dataset.new", &json!({"name": "Second", "values": {"Name": ""}})).unwrap(); + let before = s.doc().unwrap().doc.variables.clone(); + let text_only: Vec<vectorcraft_doc::DataSet> = before + .datasets + .iter() + .map(|d| { + let mut d = d.clone(); + d.values.retain(|k, _| k != "Shown"); + d + }) + .collect(); + + for opts in [ + json!({"variableData": true}), + json!({"variableData": true, "objectIds": "minimal", "minify": true}), + json!({"variableData": true, "objectIds": "unique"}), + json!({"variableData": true, "styling": "entities", "encoding": "latin1"}), + ] { + let (svg, warnings) = svg_export(&mut s, opts.clone()); + assert!(warnings.contains(&json!("Variables that aren't text variables were left out of the SVG's variable data")), "{opts}: {warnings:?}"); + assert_eq!(svg.matches("docRef=\"id('").count(), 2, "{opts}: {svg}"); + assert_eq!(s.doc().unwrap().doc.variables, before, "{opts}: exporting changes nothing"); + + let (mut o, warnings) = svg_opened(&svg, "vars.svg"); + assert!(warnings.is_empty(), "{opts}: {warnings:?}"); + let vars = o.doc().unwrap().doc.variables.clone(); + assert_eq!(vars.variables.iter().map(|v| (v.name.as_str(), v.kind.label())).collect::<Vec<_>>(), [("Name", "text"), ("Price", "text")]); + assert_eq!(vars.datasets, text_only, "{opts}"); + assert_eq!(vars.active_dataset, None); + let bound = |name: &str| { + let ids = vars.objects_of(name); + assert_eq!(ids.len(), 1, "{opts}: {name}"); + ids[0] + }; + let (name, price) = (bound("Name"), bound("Price")); + assert_eq!((plain(&o, name), plain(&o, price)), ("Alice".into(), "9.99".into()), "{opts}: the art stays as the file draws it"); + let v = o.execute("dataset.select", &json!({"name": "A & <B>"})).unwrap(); + assert_eq!(v["applied"], json!(2), "{opts}: {v}"); + assert_eq!(plain(&o, name), "Bob"); + + let mut lib = session(); + lib.execute("variable.loadLibrary", &json!({"xml": svg})).unwrap(); + assert_eq!(lib.doc().unwrap().doc.variables.datasets, text_only, "{opts}"); + } +} + +/// Without Include Variable Data the SVG holds no variable data, and a document without variables +/// writes none with it on. Outlined type is written unbound, characters XML can't hold are left +/// out of the values, and variable data past the 8 MiB or 2,000,000-node cap is left out, each +/// with a warning; past 256 text variables or 1,000 data sets the rest are left out with a +/// warning. Look-alike text in comments and other markup is skipped when the file opens. A file +/// whose variable data can't be read opens its art with a warning, and so does one whose +/// namespace entities would grow the library past its cap. +#[test] +fn an_svg_without_readable_variable_data_opens_its_art() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let (plain_svg, _) = svg_export(&mut s, json!({})); + assert!(!plain_svg.contains("variableSets")); + let (none, _) = svg_export(&mut s, json!({"variableData": true})); + assert!(!none.contains("variableSets"), "no variables, no variable data"); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + let (off, _) = svg_export(&mut s, json!({})); + assert!(!off.contains("variableSets"), "off by default"); + // Outlined type opens as art: the variable is written unbound, and the values lose the + // characters XML can't hold, each with a warning. + s.execute("dataset.new", &json!({"name": "Row", "values": {"Name": "a\u{b}b\u{1}c"}})).unwrap(); + let (outlined, warnings) = svg_export(&mut s, json!({"variableData": true, "outlineText": true})); + assert!(outlined.contains("<variable varName=\"Name\"") && !outlined.contains("docRef"), "{outlined}"); + for w in [ + "Type written as outlines opens as art: its text variables were written unbound in the SVG's variable data", + "Characters XML can't hold were left out of data set values in the SVG's variable data", + ] { + assert!(warnings.contains(&json!(w)), "{warnings:?}"); + } + let (o, _) = svg_opened(&outlined, "outlined.svg"); + let vars = &o.doc().unwrap().doc.variables; + assert!(vars.bindings.is_empty() && vars.variables.len() == 1, "{vars:?}"); + assert_eq!(vars.datasets[0].values["Name"], vectorcraft_doc::DataValue::Text("a bc".into())); + // Variable data larger than Load Variable Library reads is left out with a warning. + s.execute("dataset.new", &json!({"name": "Huge", "values": {"Name": "x".repeat(8 << 20)}})).unwrap(); + let (huge, warnings) = svg_export(&mut s, json!({"variableData": true})); + assert!(!huge.contains("variableSets")); + assert!(warnings.contains(&json!("The variable data is larger than 8 MiB, which Load Variable Library reads, and was left out of the SVG"))); + + let head = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"20\">"; + let art = "<rect x=\"1\" y=\"1\" width=\"5\" height=\"5\"/></svg>"; + let untraited = format!("{head}<variableSets><variableSet><variables><variable varName=\"A\"/></variables></variableSet></variableSets>{art}"); + let refs = "&ns_big;".repeat(40_000); + let big = "x".repeat(250); + let growing = format!( + "<!DOCTYPE svg [<!ENTITY ns_big \"{big}\">]>{head}<variableSets><variableSet><variables><variable varName=\"A\" trait=\"textcontent\" category=\"{refs}\"/></variables></variableSet></variableSets>{art}" + ); + // Variable data under 8 MiB with more XML nodes than Load Variable Library reads (a million + // one-character paragraphs, minified) is left out with a warning. + let mut lines = session(); + lines.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + lines.execute("dataset.new", &json!({"name": "Row", "values": {"Name": "a\n".repeat(1_000_000)}})).unwrap(); + let (dense, warnings) = svg_export(&mut lines, json!({"variableData": true, "minify": true})); + assert!(!dense.contains("variableSets")); + assert!( + warnings.contains(&json!( + "The variable data holds more than 2,000,000 XML nodes, which Load Variable Library reads, and was left out of the SVG" + )), + "{warnings:?}" + ); + + // More text variables and data sets than Load Variable Library reads: the first 256 and the + // first 1,000 are written, each with a warning, and the file opens with them. + let mut many = session(); + { + let vars = &mut std::sync::Arc::make_mut(&mut many.doc_mut().unwrap().doc).variables; + vars.variables = (0..300).map(|i| vectorcraft_doc::Variable { name: format!("V{i}"), kind: vectorcraft_doc::VariableKind::Text }).collect(); + vars.datasets = (0..1_100) + .map(|i| vectorcraft_doc::DataSet { + name: format!("D{i}"), + values: [("V0".to_string(), vectorcraft_doc::DataValue::Text(format!("{i}")))].into_iter().collect(), + }) + .collect(); + } + let (capped, warnings) = svg_export(&mut many, json!({"variableData": true})); + for w in [ + "Text variables past the first 256, which Load Variable Library reads, were left out of the SVG's variable data", + "Data sets past the first 1,000, which Load Variable Library reads, were left out of the SVG's variable data", + ] { + assert!(warnings.contains(&json!(w)), "{warnings:?}"); + } + let (o, warnings) = svg_opened(&capped, "many.svg"); + assert!(warnings.is_empty(), "{warnings:?}"); + let vars = &o.doc().unwrap().doc.variables; + assert_eq!((vars.variables.len(), vars.datasets.len()), (256, 1_000)); + assert_eq!(vars.datasets[999].values["V0"], vectorcraft_doc::DataValue::Text("999".into())); + + // Text that only looks like variable data, in a comment, a CDATA section, a processing + // instruction or the document type declaration, is skipped for the element itself. + let library = "<variableSets><variableSet><variables><variable varName=\"A\" trait=\"textcontent\"/></variables></variableSet></variableSets>"; + let decoys = format!( + "<!DOCTYPE svg [<!-- > <variableSets> -->]>{head}<!-- a > <variableSets> --><metadata><![CDATA[ a > <variableSets> ]]></metadata><?pi a > <variableSets> ?>{library}<!-- </variableSets> -->{art}" + ); + let (o, warnings) = svg_opened(&decoys, "decoys.svg"); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(o.doc().unwrap().doc.variables.variables.len(), 1); + + for (svg, why) in [(untraited, "variable `A` has no `trait`"), (growing, "entities expand to more than")] { + let (o, warnings) = svg_opened(&svg, "broken.svg"); + assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("variable data wasn't read") && w.contains(why))), "{warnings:?}"); + let d = &o.doc().unwrap().doc; + assert!(d.variables.variables.is_empty()); + assert_eq!(vectorcraft_testkit::svg_shapes(&vectorcraft_svg::export(d, &Default::default())), 1, "the art opens"); + } +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 8eb18626f3..5fed1fb121 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -26,6 +26,7 @@ mod nodes; mod output; mod paint; mod text; +mod variables; use nodes::SymbolDef; pub(crate) use nodes::{MASK_FLAGS, Reuse}; @@ -117,8 +118,10 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: layer_nest: 0, non_latin1_names: false, foreign_ns: BTreeMap::new(), + bound: HashMap::new(), }; w.assign_name_ids(); + w.assign_bound_ids(); for layer in &doc.layers { w.count_colorized_images(layer); } @@ -135,6 +138,8 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: w.line("</g>"); } + // Include Variable Data, once the body has written the bound objects' ids. + let variables = w.variable_sets(); // XML names cannot use character references. Preserve names that Latin-1 cannot encode. let encoding = if opts.encoding == Encoding::Latin1 && w.non_latin1_names { w.warn("Unicode data attribute names require UTF-8; SVG was written in UTF-8"); @@ -207,6 +212,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: out.push_str(&w.indent(1)); out.push_str(&format!("<script xlink:href=\"{}\"/>{nl}", xml_attr_escape(file))); } + out.push_str(&variables); let latin1 = encoding == Encoding::Latin1; if latin1 { latin1_refs(&mut out); @@ -302,4 +308,7 @@ struct Writer<'a> { /// The prefix written for each namespace of other applications' attributes, by its URI /// ([`Writer::foreign_attrs`]): declared on the root. foreign_ns: BTreeMap<String, String>, + /// Include Variable Data: the objects text variables are bound to, with the element id each + /// was written with, once written ([`Writer::note_bound`]). + bound: HashMap<NodeId, variables::Bound>, } diff --git a/crates/svg/src/export/output.rs b/crates/svg/src/export/output.rs index 7c30f23f5a..dddb19b485 100644 --- a/crates/svg/src/export/output.rs +++ b/crates/svg/src/export/output.rs @@ -30,7 +30,7 @@ pub(super) fn valid_xml_value(s: &str) -> bool { } /// An XML name without a colon (an NCName): a letter or `_`, then [`xml_name_char`]s. -fn xml_ncname(s: &str) -> bool { +pub(super) fn xml_ncname(s: &str) -> bool { let mut chars = s.chars(); chars.next().is_some_and(|c| c.is_alphabetic() || c == '_') && chars.all(xml_name_char) } @@ -193,6 +193,9 @@ impl Writer<'_> { return String::new(); } let mut out = self.names.get(&n.id).map_or_else(String::new, |id| format!(" id=\"{}\"", xml_escape(id))); + if let Some(id) = self.names.get(&n.id).filter(|_| !self.bound.is_empty()).cloned() { + self.note_bound(n.id, &id); + } if let (Some(id), Some(name)) = (self.names.get(&n.id), n.name.as_deref()) && name != id { diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index e5a5248400..13e6f87d1d 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -165,6 +165,7 @@ impl Writer<'_> { } } let id = self.id_attr(n); + self.note_outlined(n.id); let a = self.attrs(&css::transparency(n)); // Text outlined because SVG text can't draw it (bent glyphs, vertical type, ruby, effects on // a path and the like) names its text for screen readers and search; Convert to Outlines diff --git a/crates/svg/src/export/variables.rs b/crates/svg/src/export/variables.rs new file mode 100644 index 0000000000..35cc83241a --- /dev/null +++ b/crates/svg/src/export/variables.rs @@ -0,0 +1,223 @@ +//! Include Variable Data: the document's text variables, its data sets and the object each text +//! variable is bound to, written as a `variableSets` element in the layout Load Variable Library +//! reads (`variableSets` › `variableSet` › `variables` of `variable` elements, and +//! `sampleDataSets` of `sampleDataSet` elements). A variable's bound object is named by its +//! element's id in a `docRef` attribute, `docRef="id('…')"`. + +use std::collections::HashSet; + +use vectorcraft_doc::{DataValue, Document, NodeId, NodeKind, VariableKind}; + +use super::Writer; +use super::output::{valid_xml_value, xml_attr_escape, xml_ncname, xml_text}; + +/// The namespace of a variable library's elements. +const NS_VARIABLES: &str = "http://ns.adobe.com/Variables/1.0/"; // brand-ok: the namespace variable libraries use + +/// The default namespace inside `sampleDataSets`, which the elements holding values are in. +const NS_CUSTOM: &str = "http://ns.adobe.com/GenericCustomNamespace/1.0/"; // brand-ok: the namespace variable libraries use + +/// The `category` of a text variable. +const NS_FLOWS: &str = "http://ns.adobe.com/Flows/1.0/"; // brand-ok: the namespace variable libraries use + +/// The most variable data a file holds: the largest variable library Load Variable Library reads. +const MAX_VARIABLE_DATA: usize = 8 << 20; + +/// The most variables a variable library Load Variable Library reads declares. +const MAX_VARIABLES: usize = 256; + +/// The most data sets a variable library Load Variable Library reads holds. +const MAX_DATASETS: usize = 1_000; + +/// The most XML nodes (elements and text) a variable library Load Variable Library reads holds. +const MAX_NODES: usize = 2_000_000; + +/// The text variables of `doc` that a variable library can hold: those whose names can name an +/// element, the first [`MAX_VARIABLES`] of them. +fn written_variables(doc: &Document) -> Vec<&str> { + nameable_variables(doc).take(MAX_VARIABLES).collect() +} + +/// The text variables of `doc` whose names can name an element. +fn nameable_variables(doc: &Document) -> impl Iterator<Item = &str> { + doc.variables.variables.iter().filter(|v| v.kind == VariableKind::Text && xml_ncname(&v.name)).map(|v| v.name.as_str()) +} + +/// The object each text variable of `doc` that a library can hold names: the first of its bound +/// objects (by object id) that is a type object and that no variable before it names. +fn bound_objects(doc: &Document) -> Vec<(&str, NodeId)> { + let mut taken = HashSet::new(); + written_variables(doc) + .into_iter() + .filter_map(|name| { + let id = doc + .variables + .objects_of(name) + .into_iter() + .find(|id| !taken.contains(id) && doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))))?; + taken.insert(id); + Some((name, id)) + }) + .collect() +} + +impl Writer<'_> { + /// With Include Variable Data, give each object a text variable names an id when its name gave + /// it none (an unnamed object, or Minimal object ids). Called after the named objects' ids are + /// taken. + pub(super) fn assign_bound_ids(&mut self) { + if !self.opts.variable_data { + return; + } + let doc = self.doc; + for (name, id) in bound_objects(doc) { + if !self.names.contains_key(&id) { + let cand = self.unique_id(&super::sanitize_id(name)); + self.names.insert(id, cand); + } + self.bound.insert(id, Bound::Pending); + } + } + + /// Note that the object `id` was written with the element id `element`, when a variable names + /// it (the first element written for it). + pub(super) fn note_bound(&mut self, id: NodeId, element: &str) { + if let Some(slot) = self.bound.get_mut(&id) + && *slot == Bound::Pending + { + *slot = Bound::Written(element.to_string()); + } + } + + /// Note that the type object `id` was written as outlines, which open as art rather than type. + pub(super) fn note_outlined(&mut self, id: NodeId) { + if let Some(slot) = self.bound.get_mut(&id) { + *slot = Bound::Outlined; + } + } + + /// The `variableSets` element, indented one level, or nothing when the option is off or the + /// document has no text variable a library can hold. Variables of other kinds, names that + /// can't name an element, and data sets whose names are blank, have white space at either end, + /// hold characters XML can't hold or repeat a name are left out, each kind with a warning. A + /// variable whose object this file doesn't hold, or holds as outlines, is written without a + /// `docRef` (outlines with a warning). Characters XML can't hold are left out of the values, + /// with a warning. Variable data larger than Load Variable Library reads + /// ([`MAX_VARIABLE_DATA`]) is left out with a warning. + pub(super) fn variable_sets(&mut self) -> String { + let doc = self.doc; + let vars = &doc.variables; + if !self.opts.variable_data || vars.variables.is_empty() { + return String::new(); + } + if vars.variables.iter().any(|v| v.kind != VariableKind::Text) { + self.warn("Variables that aren't text variables were left out of the SVG's variable data"); + } + if vars.variables.iter().any(|v| v.kind == VariableKind::Text && !xml_ncname(&v.name)) { + self.warn("Text variables whose names can't name an XML element were left out of the SVG's variable data"); + } + let names = written_variables(doc); + if names.is_empty() { + return String::new(); + } + if nameable_variables(doc).nth(MAX_VARIABLES).is_some() { + self.warn("Text variables past the first 256, which Load Variable Library reads, were left out of the SVG's variable data"); + } + // XML names can't be written as character references. + self.non_latin1_names |= names.iter().any(|n| n.chars().any(|c| u32::from(c) > 0xff)); + let bound = bound_objects(doc); + let slots: Vec<Option<&Bound>> = + names.iter().map(|name| bound.iter().find(|(n, _)| n == name).and_then(|(_, id)| self.bound.get(id))).collect(); + let outlined = slots.contains(&Some(&Bound::Outlined)); + let refs: Vec<Option<String>> = slots + .into_iter() + .map(|s| match s { + Some(Bound::Written(e)) => Some(e.clone()), + _ => None, + }) + .collect(); + if outlined { + self.warn("Type written as outlines opens as art: its text variables were written unbound in the SVG's variable data"); + } + let mut seen = HashSet::new(); + let mut datasets: Vec<_> = vars + .datasets + .iter() + .filter(|ds| !ds.name.is_empty() && ds.name.trim() == ds.name && valid_xml_value(&ds.name) && seen.insert(ds.name.as_str())) + .collect(); + if datasets.len() < vars.datasets.len() { + self.warn( + "Data sets whose names are blank, start or end with white space, hold characters XML can't hold or repeat a name were left out of the SVG's variable data", + ); + } + if datasets.len() > MAX_DATASETS { + datasets.truncate(MAX_DATASETS); + self.warn("Data sets past the first 1,000, which Load Variable Library reads, were left out of the SVG's variable data"); + } + let values = || datasets.iter().flat_map(|ds| names.iter().filter_map(|n| ds.values.get(*n))); + if values().any(|v| matches!(v, DataValue::Text(t) if !valid_xml_value(t))) { + self.warn("Characters XML can't hold were left out of data set values in the SVG's variable data"); + } + let nl = if self.opts.minify { "" } else { "\n" }; + let mut out = String::new(); + // The XML nodes the reader makes: each start tag an element, the white space before each + // line a text node unless minified, and each paragraph's text another. + let mut nodes = 0usize; + let mut line = |depth: usize, s: &str| { + nodes += usize::from(!s.starts_with("</")) + usize::from(!self.opts.minify) + usize::from(s.starts_with("<p>") && s != "<p></p>"); + out.push_str(&self.indent(depth)); + out.push_str(s); + out.push_str(nl); + }; + line(1, &format!("<variableSets xmlns=\"{NS_VARIABLES}\">")); + line(2, "<variableSet varSetName=\"binding1\" locked=\"none\">"); + line(3, "<variables>"); + for (name, element) in names.iter().zip(&refs) { + let doc_ref = element.as_ref().map_or_else(String::new, |e| format!(" docRef=\"id('{}')\"", xml_attr_escape(e))); + line(4, &format!("<variable varName=\"{name}\" trait=\"textcontent\" category=\"{NS_FLOWS}\"{doc_ref}></variable>")); + } + line(3, "</variables>"); + line(3, &format!("<v:sampleDataSets xmlns:v=\"{NS_VARIABLES}\" xmlns=\"{NS_CUSTOM}\">")); + for ds in datasets { + line(4, &format!("<v:sampleDataSet dataSetName=\"{}\">", xml_attr_escape(&ds.name))); + for name in &names { + let Some(DataValue::Text(t)) = ds.values.get(*name) else { continue }; + line(5, &format!("<{name}>")); + // One paragraph per line; a carriage return as a reference, which reads back as itself. + for para in t.split('\n') { + line(6, &format!("<p>{}</p>", xml_text(para).replace('\r', " "))); + } + line(5, &format!("</{name}>")); + } + line(4, "</v:sampleDataSet>"); + } + line(3, "</v:sampleDataSets>"); + line(2, "</variableSet>"); + line(1, "</variableSets>"); + // Measured as written: a Latin-1 file holds other characters as references. + let mut written = std::borrow::Cow::Borrowed(out.as_str()); + if self.opts.encoding == crate::Encoding::Latin1 { + super::latin1_refs(written.to_mut()); + } + if written.len() > MAX_VARIABLE_DATA { + self.warn("The variable data is larger than 8 MiB, which Load Variable Library reads, and was left out of the SVG"); + return String::new(); + } + if nodes > MAX_NODES { + self.warn("The variable data holds more than 2,000,000 XML nodes, which Load Variable Library reads, and was left out of the SVG"); + return String::new(); + } + out + } +} + +/// What an object a text variable names became in the file. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum Bound { + /// Not written yet. + Pending, + /// Written as an element with this id. + Written(String), + /// Written as outlines, which open as art. + Outlined, +} diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 4ac3530b19..897f3fb5cf 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -117,6 +117,17 @@ pub(crate) fn svg_filters(svg: &str, room: usize, taken: impl Fn(&str) -> bool) /// The document SVG text `svg` holds, read with `opts`, and the warnings about what it couldn't keep. pub(crate) fn import(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Result<(Document, Vec<String>), SvgError> { + import_finding(svg, opts, max_nodes, &HashSet::new()).map(|(doc, warnings, _)| (doc, warnings)) +} + +/// [`import`], also finding the object made of each element whose id `wanted` lists (the first +/// object named after it), by element id. +pub(crate) fn import_finding( + svg: &str, + opts: &ImportOptions, + max_nodes: usize, + wanted: &HashSet<String>, +) -> Result<(Document, Vec<String>, crate::Found), SvgError> { // Before anything reads the tree: the XML reader, usvg, and what is made of its tree, all by // recursion. let too_deep = || SvgError::TooDeep(depth::MAX_ELEMENT_DEPTH); @@ -191,6 +202,8 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Resul max_nodes, error: None, nested_svg_images: 0, + wanted: wanted.clone(), + found: HashMap::new(), }; im.doc.svg_scripts = found.scripts; if im.files.nested_text() { @@ -303,7 +316,7 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Resul } } im.doc.limit_depth_noting(&mut im.warnings); - Ok((im.doc, im.warnings)) + Ok((im.doc, im.warnings, im.found)) } struct Importer { @@ -365,4 +378,8 @@ struct Importer { error: Option<SvgError>, /// How many nested SVG images were converted so far. nested_svg_images: usize, + /// The element ids whose objects [`import_finding`] looks for. + wanted: HashSet<String>, + /// The object made of each element `wanted` lists, by its id. + found: HashMap<String, NodeId>, } diff --git a/crates/svg/src/import/importer.rs b/crates/svg/src/import/importer.rs index 33878ac998..306a0c17d2 100644 --- a/crates/svg/src/import/importer.rs +++ b/crates/svg/src/import/importer.rs @@ -140,12 +140,16 @@ impl Importer { Some(self.doc.alloc_id()) } - /// A new object named after element `id` (unless it has none or a made-up one). + /// A new object named after element `id` (unless it has none or a made-up one), noted as + /// found when [`Importer::wanted`] lists `id`. fn named(&mut self, id: &str, kind: NodeKind) -> Option<Node> { let nid = self.alloc_id()?; let mut n = Node::new(nid, kind); if !id.is_empty() && !made_up(id) { n.name = Some(self.name_of(id).to_string()); + if self.wanted.contains(id) { + self.found.entry(id.to_string()).or_insert(nid); + } } Some(n) } @@ -861,6 +865,8 @@ mod tests { max_nodes: 1000, error: None, nested_svg_images: crate::MAX_NESTED_SVG_IMAGES, + wanted: HashSet::new(), + found: HashMap::new(), }; let tree = usvg::Tree::from_str(r#"<svg xmlns="http://www.w3.org/2000/svg"/>"#, &usvg::Options::default()).unwrap(); let res = im.svg_image(&tree, Affine::IDENTITY); diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 066392bb87..eaf1355e93 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -246,6 +246,10 @@ pub struct ExportOptions { /// its licence allows (whole where it forbids subsetting, left out where it forbids /// embedding). Not with outlined text or the Tiny profile. pub embed_fonts: bool, + /// Include Variable Data: write the document's text variables, its data sets and the object + /// each text variable is bound to as a `variableSets` element, in the layout Load Variable + /// Library reads. Opening the SVG reads them back. + pub variable_data: bool, } impl Default for ExportOptions { @@ -267,6 +271,7 @@ impl Default for ExportOptions { encoding: Encoding::Utf8, profile: Profile::Svg11, embed_fonts: false, + variable_data: false, } } } @@ -577,6 +582,15 @@ pub fn import_within(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Resul vectorcraft_doc::depth::with_deep_stack(|| import::import(svg, opts, max_nodes)) } +/// [`import_with`], also returning the object made of each element whose id `ids` lists (the +/// first object named after it), by element id. Ids with no object are left out. +pub fn import_finding(svg: &str, opts: &ImportOptions, ids: &std::collections::HashSet<String>) -> Result<(Document, Vec<String>, Found), SvgError> { + vectorcraft_doc::depth::with_deep_stack(|| import::import_finding(svg, opts, MAX_NODES, ids)) +} + +/// The object made of each element [`import_finding`] looked for, by element id. +pub type Found = std::collections::HashMap<String, vectorcraft_doc::NodeId>; + /// Reads a linked file: its bytes and the link to it (absolute path, size, time and hash), or /// `None` when there is no readable file at the (absolute) path. pub type ReadFile<'a> = &'a (dyn Fn(&str) -> Option<(Vec<u8>, vectorcraft_doc::LinkInfo)> + Sync); diff --git a/crates/ui-egui/src/dialogs/svg_options.rs b/crates/ui-egui/src/dialogs/svg_options.rs index 28939b0683..b32f520bcb 100644 --- a/crates/ui-egui/src/dialogs/svg_options.rs +++ b/crates/ui-egui/src/dialogs/svg_options.rs @@ -227,6 +227,9 @@ pub(super) fn option_fields(ui: &mut egui::Ui, d: &mut Dialog, screens: bool) { check(ui, d, "embedFonts", tl!("Embed Fonts (Glyphs Used)"), !d.bool("outlineText") && !tiny); check(ui, d, "fewerTspans", tl!("Fewer <tspan> Elements"), !d.bool("outlineText")); check(ui, d, "metadata", tl!("Include Metadata"), true); + if !screens { + check(ui, d, "variableData", tl!("Include Variable Data"), true); + } } /// Use Artboards: all of them or a range; off = the bounds of all art. @@ -443,6 +446,24 @@ mod tests { assert_eq!((written.borrow().len(), written.borrow()[1].0.as_str()), (2, "/tmp/doc.svg")); } + /// Include Variable Data starts off, draws in the dialog, and writes the text variables and + /// data sets into the saved SVG when it is on. + #[test] + fn include_variable_data_reaches_the_file() { + let (mut app, written) = app("/tmp/vars.svg"); + let id = app.run("text.create", json!({"x": 10, "y": 60, "text": "Alice"})).unwrap()["id"].clone(); + app.run("variable.define", json!({"name": "Name", "kind": "text"})).unwrap(); + app.run("variable.bind", json!({"variable": "Name", "ids": [id]})).unwrap(); + app.run("dataset.new", json!({"name": "Row", "values": {"Name": "Bob"}})).unwrap(); + app.run("file.saveAs", Value::Null).unwrap(); + assert!(!app.ui.dialog.as_ref().unwrap().bool("variableData"), "off at first"); + frame(&mut app); + app.ui.dialog.as_mut().unwrap().fields.insert("variableData".into(), json!(true)); + super::super::confirm(&mut app).unwrap(); + let svg = text(&written, 0); + assert!(svg.contains("<variable varName=\"Name\"") && svg.contains("dataSetName=\"Row\"") && svg.contains("<p>Bob</p>"), "{svg}"); + } + #[test] fn profile_encoding_and_embedded_fonts_reach_the_file() { let (mut app, written) = app("/tmp/tiny.svg"); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 4f7136bdad..6d4dd10ae4 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1634,6 +1634,7 @@ font-technology Variable Променливи Include Hidden Layers Включване на скритите слоеве Include Linked Files Включване на свързаните файлове Include Metadata Включване на метаданни + Include Variable Data Включване на данни за променливи Include non-printing layers Включване на слоеве, които не се печат Write pages longer than 200 in at full size in points Записване на страници, по-дълги от 200 in, в пълен размер в точки Include SVG Code Включване на SVG код diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 0e7dd028fd..9a2129e100 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -3089,6 +3089,7 @@ antiAlias Sharp Oštro Hide Code Sakrij kod Images: Slike: Include Metadata Uključi metapodatke + Include Variable Data Uključi podatke varijabli Internal CSS Interni CSS Layer Names Nazivi slojeva Minify Minificiraj diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 2cac71874d..fb2ca918d2 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2105,6 +2105,7 @@ digits Malayalam Malaiàlam Include Hidden Layers Inclou les capes ocultes Include Linked Files Inclou els fitxers enllaçats Include Metadata Inclou les metadades + Include Variable Data Inclou les dades de variables Include non-printing layers Inclou les capes no imprimibles Write pages longer than 200 in at full size in points Escriu les pàgines de més de 200 in a mida real, en punts Include Objects On Canvas Inclou els objectes del llenç diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6a74590c93..31eac2ac0b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3125,6 +3125,7 @@ antiAlias Sharp Ostré Hide Code Skrýt kód Images: Obrázky: Include Metadata Zahrnout metadata + Include Variable Data Zahrnout data proměnných Internal CSS Interní CSS Layer Names Názvy vrstev Minify Minimalizovat diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 2b7d6edd5a..97bb4d6838 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2909,6 +2909,7 @@ antiAlias Sharp Skarp Hide Code Skjul kode Images: Billeder: Include Metadata Medtag metadata + Include Variable Data Medtag variabeldata Internal CSS Intern CSS Layer Names Lagnavne Minify Minificér diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index bedf2af395..ced2bed156 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1693,6 +1693,7 @@ file-filter Templates Vorlagen Include Hidden Layers Ausgeblendete Ebenen einschließen Include Linked Files Verknüpfte Dateien einschließen Include Metadata Metadaten einschließen + Include Variable Data Variablendaten einschließen Include non-printing layers Nicht druckende Ebenen einschließen Write pages longer than 200 in at full size in points Seiten über 200 in in voller Größe in Punkt schreiben Include SVG Code SVG-Code einschließen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6a693248c0..9cccea642b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1608,6 +1608,7 @@ font-technology Variable Μεταβλητές Include Hidden Layers Συμπερίληψη κρυφών επιπέδων Include Linked Files Συμπερίληψη συνδεδεμένων αρχείων Include Metadata Συμπερίληψη μεταδεδομένων + Include Variable Data Συμπερίληψη δεδομένων μεταβλητών Include non-printing layers Συμπερίληψη μη εκτυπώσιμων επιπέδων Write pages longer than 200 in at full size in points Εγγραφή σελίδων μεγαλύτερων από 200 in σε πλήρες μέγεθος, σε στιγμές Include SVG Code Συμπερίληψη κώδικα SVG diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 28714d37cf..d4ba93fe8d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1689,6 +1689,7 @@ font-technology Variable Variables Include Hidden Layers Incluir capas ocultas Include Linked Files Incluir archivos vinculados Include Metadata Incluir metadatos + Include Variable Data Incluir datos de variables Include non-printing layers Incluir capas no imprimibles Write pages longer than 200 in at full size in points Escribir las páginas de más de 200 in a tamaño real, en puntos Include SVG Code Incluir código SVG diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index fc809c8928..60fbc48516 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3069,6 +3069,7 @@ antiAlias Sharp Terävä Hide Code Piilota koodi Images: Kuvat: Include Metadata Sisällytä metatiedot + Include Variable Data Sisällytä muuttujatiedot Internal CSS Sisäinen CSS Layer Names Tasojen nimet Minify Pienennä diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 0d61a8cf77..1d3abb61ab 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1683,6 +1683,7 @@ font-technology Variable Variables Include Hidden Layers Inclure les calques masqués Include Linked Files Inclure les fichiers liés Include Metadata Inclure les métadonnées + Include Variable Data Inclure les données des variables Include non-printing layers Inclure les calques non imprimables Write pages longer than 200 in at full size in points Écrire les pages de plus de 200 in en taille réelle, en points Include SVG Code Inclure le code SVG diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 902a6b5978..13a1a48f58 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2323,6 +2323,7 @@ antiAlias Sharp शार्प Include in playback प्लेबैक में शामिल करें Include Linked Files लिंक्ड फ़ाइलें शामिल करें Include Metadata मेटाडाटा शामिल + Include Variable Data वेरिएबल डेटा शामिल Include non-printing layers गैर-प्रिंटिंग लेयर्स को शामिल करें Write pages longer than 200 in at full size in points 200 in से लंबे पेज पूरे आकार में, पॉइंट में लिखें Include Objects On Canvas कैनवास पर ऑब्जेक्ट्स शामिल करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 1028d6d3fc..59308260e0 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2106,6 +2106,7 @@ file-filter Templates Sablonok Include Hidden Layers Rejtett rétegek hozzáadása Include Linked Files Csatolt fájlok hozzáadása Include Metadata Metaadatok hozzáadása + Include Variable Data Változóadatok hozzáadása Include non-printing layers Nem nyomtatandó rétegek hozzáadása Write pages longer than 200 in at full size in points 200 in-nél hosszabb oldalak írása teljes méretben, pontban Include SVG Code SVG-kód hozzáadása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index c6dad138e3..3b61f2d162 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2909,6 +2909,7 @@ antiAlias Sharp Tajam Hide Code Sembunyikan kode Images: Gambar: Include Metadata Sertakan metadata + Include Variable Data Sertakan data variabel Internal CSS CSS internal Layer Names Nama lapisan Minify Minify diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 04f24c86ab..ba1fff3b3d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1724,6 +1724,7 @@ file-filter Templates Modelli Include Hidden Layers Includi livelli nascosti Include Linked Files Includi file collegati Include Metadata Includi metadati + Include Variable Data Includi dati delle variabili Include non-printing layers Includi livelli non stampabili Write pages longer than 200 in at full size in points Scrivi le pagine più lunghe di 200 in a grandezza reale, in punti Include SVG Code Includi codice SVG diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 0413cc8afc..42c4e3d188 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2065,6 +2065,7 @@ antiAlias Sharp シャープ Include Hidden Layers 非表示レイヤーを含む Include Linked Files リンクファイルを含める Include Metadata メタデータを含む + Include Variable Data 変数データを含む Include SVG Code SVG コードを含める Include Swatches for Tints ティント用のスウォッチを含める Include non-printing layers 印刷しないレイヤーを含む diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 5901488cc8..7f4c374212 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -2752,6 +2752,7 @@ antiAlias Sharp მკვეთრი Hide Code კოდის დამალვა Images: გამოსახულებები: Include Metadata მეტამონაცემების ჩართვა + Include Variable Data ცვლადების მონაცემების ჩართვა Internal CSS შიდა CSS Layer Names შრეების სახელები Minify მინიფიკაცია diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 1db3500541..dc61d87484 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2094,6 +2094,7 @@ antiAlias Sharp 날카롭게 Include Hidden Layers 숨겨진 레이어 포함 Include Linked Files 링크된 파일 포함 Include Metadata 메타데이터 포함 + Include Variable Data 변수 데이터 포함 Include SVG Code SVG 코드 포함 Include Swatches for Tints 농도별 견본 포함 Include non-printing layers 인쇄되지 않는 레이어 포함 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 0400283630..a9455ea66e 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2923,6 +2923,7 @@ antiAlias Sharp Scherp Hide Code Code verbergen Images: Afbeeldingen: Include Metadata Metadata opnemen + Include Variable Data Variabelegegevens opnemen Internal CSS Interne CSS Layer Names Laagnamen Minify Minify diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 6e595e8673..fb597012dd 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3010,6 +3010,7 @@ antiAlias Sharp Ostre Hide Code Ukryj kod Images: Obrazy: Include Metadata Dołącz metadane + Include Variable Data Dołącz dane zmiennych Internal CSS Wewnętrzny CSS Layer Names Nazwy warstw Minify Minimalizuj diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index d20df47512..e9a05bcd67 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1484,6 +1484,7 @@ antiAlias Sharp Nítido Include Hidden Layers Incluir Camadas Ocultas Include Linked Files Incluir Arquivos Vinculados Include Metadata Incluir Metadados + Include Variable Data Incluir Dados de Variáveis Include SVG Code Incluir Código SVG Include Swatches for Tints Incluir Amostras para Tons Include non-printing layers Incluir camadas de não impressão diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9d1e2d0035..fa6f755590 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1713,6 +1713,7 @@ file-filter Templates Шаблоны Include Hidden Layers Включать скрытые слои Include Linked Files Включать связанные файлы Include Metadata Включать метаданные + Include Variable Data Включать данные переменных Include non-printing layers Включать непечатаемые слои Write pages longer than 200 in at full size in points Записывать страницы длиннее 200 in в полном размере, в пунктах Include SVG Code Включать код SVG diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 3c7e078fed..116b6722a1 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1647,6 +1647,7 @@ font-technology Variable Variabla Include Hidden Layers Inkludera dolda lager Include Linked Files Inkludera länkade filer Include Metadata Inkludera metadata + Include Variable Data Inkludera variabeldata Include non-printing layers Inkludera lager som inte skrivs ut Write pages longer than 200 in at full size in points Skriv sidor längre än 200 in i full storlek i punkter Include SVG Code Inkludera SVG-kod diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 939dadc352..4dd3a042b7 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1527,6 +1527,7 @@ font-technology Variable Değişken Include Hidden Layers Gizli Katmanları Ekle Include Linked Files Bağlantılı Dosyaları Ekle Include Metadata Meta Verileri Ekle + Include Variable Data Değişken Verilerini Ekle Include non-printing layers Yazdırılmayan katmanları dahil et Write pages longer than 200 in at full size in points 200 in'den uzun sayfaları tam boyutta, punto olarak yaz Include SVG Code SVG Kodunu Ekle diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0b6d6abd1e..7186020ebf 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1718,6 +1718,7 @@ file-filter Templates Шаблони Include Hidden Layers Включати приховані шари Include Linked Files Включати пов’язані файли Include Metadata Включати метадані + Include Variable Data Включати дані змінних Include non-printing layers Включати недруковані шари Write pages longer than 200 in at full size in points Записувати сторінки, довші за 200 in, у повному розмірі, у пунктах Include SVG Code Вмикати код SVG diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 0a2145701f..f7ca5fbd79 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1995,6 +1995,7 @@ digits Hindi Hindi Include Hidden Layers Bao gồm các lớp ẩn Include Linked Files Bao gồm tệp được liên kết Include Metadata Bao gồm siêu dữ liệu + Include Variable Data Bao gồm dữ liệu biến Include non-printing layers Bao gồm các lớp không in Write pages longer than 200 in at full size in points Ghi các trang dài hơn 200 in ở kích thước đầy đủ, theo điểm Include SVG Code Bao gồm mã SVG diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 131f6bd8f2..18a87f9877 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1643,6 +1643,7 @@ file-filter Templates 模板 Include Hidden Layers 包含隐藏图层 Include Linked Files 包含链接文件 Include Metadata 包含元数据 + Include Variable Data 包含变量数据 Include non-printing layers 包含非打印图层 Write pages longer than 200 in at full size in points 以实际尺寸(点)写入长于 200 in 的页面 Include SVG Code 包含 SVG 代码 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index feba6fd9df..9407b5778d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1689,6 +1689,7 @@ file-filter Templates 範本 Include Hidden Layers 包含隱藏圖層 Include Linked Files 包含連結檔案 Include Metadata 包含中繼資料 + Include Variable Data 包含變數資料 Include non-printing layers 包含不列印的圖層 Write pages longer than 200 in at full size in points 以實際尺寸(點)寫入長於 200 in 的頁面 Include SVG Code 包含 SVG 程式碼 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9db1e9f90a..d7c757f1dd 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -302,7 +302,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | PDF | open, place, save | yes (authored ActualText in editable import, #6165; layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313); an image in a Separation space opens as a grayscale image of its tints colorized by its ink at 100 % (a spot swatch, a process plate or Registration), its soft mask as its opacity mask (#6314); a DeviceN image of one colorant opens the same way, and one of process plates only (the ink `None` may be among them) opens in CMYK with each colorant's tint in its plate (#6348) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. | | EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined CMap (`UniJIS-UCS2-H`, `90ms-RKSJ-H`, `GBK-EUC-H`, `KSCms-UHC-H`, `ETen-B5-H`), set as type in the font of the CIDFont's name, vertical over a `-V` CMap; a name that ends in the row-and-cell CMap `H` or `V` splits off the CMap only when the file defines the CIDFont of that name, or the name has a hyphen and an installed face that covers Japanese has it; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | -| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`) | +| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`); SVG Options › Include Variable Data writes the text variables, data sets and bound type objects, and opening the SVG reads them back (`tests::variables::an_svg_with_variable_data_opens_with_its_variables_data_sets_and_bindings`) | | DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500); VCarve QA remains open | | HP-GL/2 `.plt`, `.hpgl`, `.hgl`, `.hpg` | — | yes (pen moves, pens and widths, line types and attributes, circles, arcs, wedges and Bézier curves, filled and edged rectangles, wedges and polygons, encoded polylines, scaling and rotation; labels, symbol mode, raster images and hatching aren't drawn) | yes (`.plt`: each visible path once along its line, a pen per color or a pen table, type as outlines, clipping applied, HP-GL or HP-GL/2 with pen colors and encoded polylines) | recognized by its first instructions after any printer escapes, under its own extensions or one no other format reads; `crates/cad/src/hpgl/tests.rs`, `crates/cad/src/hpgl/write/tests.rs` (plots read back by the reader), `tests_hpgl.rs`, `import_fuzz.rs` | | DWG | open, export | no | no | no open spec; DXF instead | diff --git a/docs/mcp.md b/docs/mcp.md index 8181e111a3..97e9e08d97 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -777,7 +777,20 @@ lists every option with its default. `encoding` is `utf8`, `utf16` (big-endian a a warning). `embedFonts: true` embeds the fonts type uses as `@font-face` rules subset to the characters used; a font whose licence (OS/2 `fsType`) forbids subsetting is embedded whole, one that forbids embedding is left out with a warning. Type names its faces by numeric `font-weight` (600 for Semibold and for a Japanese face's W6, 300 for Light, `bold` for 700), -embedded or not. `svgz` takes the same options and writes the SVG gzipped (`.svgz` files also open, +embedded or not. `variableData: true` (SVG Options › Include Variable Data) writes the document's text variables and +data sets as a `variableSets` element in the layout `variable.loadLibrary` reads, so the SVG works as a data merge +template ([help](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/merge-data.html)). +Each text variable names its bound type object by the element's id, `docRef="id('…')"` (VectorCraft's own form); an +unnamed object, or any object with `objectIds: "minimal"`, is given an id for it. A variable bound to several type +objects names the first by object id. Variables of other kinds, text variables whose names aren't XML names, and data +sets whose names are blank, have white space at either end, hold characters XML can't hold or repeat a name are left +out, each with a warning. Only the first 256 text variables and the first 1,000 data sets are written, and variable +data larger than 8 MiB or holding more than 2,000,000 XML nodes is left out (the limits `variable.loadLibrary` +reads), each with a warning. With `outlineText`, the bound type is written as outlines, so its text variables open +unbound (with a warning). `document.open` reads the variable data of an SVG back (unless the SVG reopens from +Preserve Editing): its text variables and data sets, no active data set, and each text variable bound to the type +object its `docRef` names; the art stays as the file draws it. Variable data that `variable.loadLibrary` would refuse +is left out with the warning "the file's variable data wasn't read", and the art opens. `svgz` takes the same options and writes the SVG gzipped (`.svgz` files also open, place and paste). `run_command document.save {path: "x.svg", svg: {…}}` saves as SVG (or `.svgz`). Colorized grayscale and 1-bit images export in their fill color; linked mode writes a coverage PNG, and Tiny 1.2 writes a colorized PNG. From eed65939fca9c2e935e5dae29412b025afad006a Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 10:25:44 -0800 Subject: [PATCH 67/92] Learn: a tool's name in a tutorial step shows where the tool is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tool step in the Learn panel now draws the tool's name as a link. Clicking it, or pressing Enter or Space on it, runs the new learn.locateTool command, which marks the tool where the current layout shows it without choosing it (the help page: https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html). - A tool shown on its toolbar button has the button marked, and a toolbar that has scrolled it out of view scrolls it back. - A tool in a collapsed group opens the group's flyout with its row marked; the group keeps the tool it shows. - A group torn off into a strip comes to the front with the tool marked. - Toolbars made with New Toolbar are searched after the Tools panel. - A tool that no shown toolbar holds is marked in the All Tools drawer. - A Tools panel hidden with Window › Tools shows again. In Presentation Mode, or while Tab hides the toolbars, the reply is "unavailable" with a reason. - The mark is a 2 pt outline in the selection color. It lasts 3 seconds, until Escape (also when Escape closes the All Tools drawer), or until the next link replaces it. These are VectorCraft's own rules. The command takes a tool id and rejects a missing, non-string or unknown id. Catalog steps mark the linked name in brackets, and the catalog test checks that each one names its tool. Rows for the command and the link's hover text are added to every complete catalog. Docs: docs/mcp.md, docs/target-app-parity.md, docs/ui-parity.md. --- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/custom_toolbars.rs | 53 ++++- crates/ui-egui/src/edit_toolbar.rs | 26 +- crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 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 + crates/ui-egui/src/menus/ui_commands.rs | 6 + crates/ui-egui/src/panels/learn.rs | 53 ++++- crates/ui-egui/src/panels/learn/catalog.rs | 60 +++-- crates/ui-egui/src/panels/learn/locate.rs | 193 +++++++++++++++ crates/ui-egui/src/panels/learn/tests.rs | 107 ++++++++- crates/ui-egui/src/state.rs | 5 + crates/ui-egui/src/toolbar.rs | 97 ++++++-- crates/ui-egui/src/toolbar/tests.rs | 264 +++++++++++++++++++++ docs/mcp.md | 18 ++ docs/target-app-parity.md | 1 + docs/ui-parity.md | 2 +- 41 files changed, 876 insertions(+), 64 deletions(-) create mode 100644 crates/ui-egui/src/panels/learn/locate.rs diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 7865375b69..6968725a83 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -601,6 +601,7 @@ impl VectorcraftApp { panels::library_panel::tidy(self); // The canvas and the panels: one docking layout. Tab hides the panels but not the canvas. egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| panel_docking::show(self, ui)); + panels::learn::locate::tidy(self, &ctx); crate::doc_windows::show(self, &ctx); dialogs::show(self, &ctx); updates_ui::show(self, &ctx); diff --git a/crates/ui-egui/src/custom_toolbars.rs b/crates/ui-egui/src/custom_toolbars.rs index 2cc23df53f..077b89efe7 100644 --- a/crates/ui-egui/src/custom_toolbars.rs +++ b/crates/ui-egui/src/custom_toolbars.rs @@ -598,6 +598,10 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let edit_this = editing.as_deref() == Some(bar.name.as_str()); let mut moved = None; let mut drops = None; + // A Learn link locating a tool of this toolbar: its button, scrolled into view once. + let locate = crate::panels::learn::locate::wanted(&app.ui, Some(&bar.name)); + let scrolled = app.ui.learn_locate.as_ref().is_some_and(|l| l.scrolled); + let (mut found, mut did_scroll) = (None, false); egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| { crate::toolbar::flyout_frame(&t, false).inner_margin(egui::Margin { left: 0, right: 0, top: 0, bottom: 4 }).show(ui, |ui| { ui.set_width(width); @@ -657,11 +661,17 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { ui.horizontal(|ui| { ui.add_space((middle - BUTTON / 2.0 * row.len() as f32 - ui.cursor().min.x).max(0.0)); for &(s, slot) in row { - if let Some(rect) = slot_button(ui, &t, &press, slot, &mut actions) - && edit_this - { + let Some((rect, shown)) = slot_button(ui, &t, &press, slot, &mut actions) else { continue }; + if edit_this { buttons.push((s, rect)); } + if let Some(tool) = locate.filter(|tool| slot.iter().any(|id| id == tool)) { + if !scrolled && !ui.clip_rect().contains_rect(rect) { + ui.scroll_to_rect(rect, None); + did_scroll = true; + } + found = Some((rect, shown == tool, slot.clone(), ui.layer_id(), ui.clip_rect())); + } } }); } @@ -697,6 +707,22 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { { b.pos = [p.x, p.y]; } + // The button is marked when it shows the located tool, else the group's flyout opens on it; + // a group torn off into a strip is marked there. + if did_scroll { + crate::panels::learn::locate::scroll_once(&mut app.ui); + ctx.request_repaint(); + } else if let Some((rect, shows, slot, layer, clip)) = found { + let tools: Vec<&'static str> = slot.iter().filter_map(|id| tool_info(id).map(|t| t.id)).collect(); + let torn_off = tools.len() > 1 && tools.first().is_some_and(|key| crate::toolbar::is_floating(app, Some(&bar.name), key)); + if !torn_off { + if shows { + crate::panels::learn::locate::mark(app, ctx, layer, clip, rect); + } else if crate::panels::learn::locate::reveal_once(&mut app.ui) { + actions.push(Action::Flyout(tools, rect, bar.name.clone())); + } + } + } } if let Some((bounds, button)) = clicked_anchor.or(anchor) { ctx.data_mut(|d| { @@ -767,13 +793,11 @@ struct Press<'a> { /// One slot's tool button, as the Tools panel's: it shows the tool chosen last from its group, a /// press selects it, and a long or right press opens the group's flyout; while the drawer edits the -/// toolbar, it drags instead. Returns where it is drawn. -fn slot_button(ui: &mut egui::Ui, t: &Tokens, p: &Press, slot: &[String], actions: &mut Vec<Action>) -> Option<egui::Rect> { +/// toolbar, it drags instead. Returns where it is drawn and the tool it shows. +fn slot_button(ui: &mut egui::Ui, t: &Tokens, p: &Press, slot: &[String], actions: &mut Vec<Action>) -> Option<(egui::Rect, &'static str)> { let tools: Vec<&'static str> = slot.iter().filter_map(|id| tool_info(id).map(|t| t.id)).collect(); - let &first = tools.first()?; let is_active = tools.contains(&p.active); - let shown_id = if is_active { p.active } else { p.bar.slot_tool.get(first).and_then(|id| tool_info(id)).map_or(first, |t| t.id) }; - let shown = tool_info(shown_id).filter(|t| tools.contains(&t.id)).or_else(|| tool_info(first))?; + let shown = tool_info(shown_tool(p.bar, p.active, &tools)?)?; let (rect, resp) = ui.allocate_exact_size(vec2(BUTTON, crate::toolbar::PITCH - 1.0), Sense::click_and_drag()); let well = egui::Rect::from_center_size(rect.center(), vec2(30.0, 27.5)); if is_active { @@ -810,7 +834,18 @@ fn slot_button(ui: &mut egui::Ui, t: &Tokens, p: &Press, slot: &[String], action let tip = crate::toolbar::tip(shown); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), is_active, &tip)); resp.on_hover_text(tip); - Some(rect) + Some((rect, shown.id)) +} + +/// The tool a slot's button on `bar` shows, of the slot's known `tools`: the active tool when the +/// slot holds it, else the one chosen last from the slot's group, else its first; none for a slot +/// with no known tool. +pub(crate) fn shown_tool(bar: &CustomToolbar, active: &'static str, tools: &[&'static str]) -> Option<&'static str> { + let &first = tools.first()?; + if tools.contains(&active) { + return Some(active); + } + Some(bar.slot_tool.get(first).and_then(|id| tools.iter().copied().find(|t| t == id)).unwrap_or(first)) } /// The area of Add Tool…'s menu on the toolbar named `name`. diff --git a/crates/ui-egui/src/edit_toolbar.rs b/crates/ui-egui/src/edit_toolbar.rs index 6f84623087..d58351ac21 100644 --- a/crates/ui-egui/src/edit_toolbar.rs +++ b/crates/ui-egui/src/edit_toolbar.rs @@ -485,6 +485,10 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) { let mut toolbars_cmd = None; let mut advanced = None; let mut show_toggle = None; + // A Learn link locating a tool that no shown toolbar holds: its row, scrolled into view once. + let locate = crate::panels::learn::locate::wanted_in_drawer(&app.ui); + let scroll = app.ui.learn_locate.as_ref().is_some_and(|l| !l.scrolled); + let (mut located, mut scrolled) = (None, false); let resp = egui::Area::new(id).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show(ctx, |ui| { crate::toolbar::flyout_frame(&t, removing).inner_margin(egui::Margin::same(8)).show(ui, |ui| { let width = WIDTH.min(screen.width() - 2.0 * GAP - 16.0).max(160.0); @@ -535,9 +539,17 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) { egui::Frame::NONE.stroke(Stroke::new(1.0, t.divider)).corner_radius(2.0).show(ui, |ui| { ui.set_width(inner); for tool in g.iter() { - if row(ui, &t, tool, tool.id == active, in_toolbar.contains(tool.id)) { + let (r, clicked) = row(ui, &t, tool, tool.id == active, in_toolbar.contains(tool.id)); + if clicked { picked = Some(tool.id); } + if locate == Some(tool.id) { + if scroll && !ui.clip_rect().contains_rect(r) { + ui.scroll_to_rect(r, Some(egui::Align::Center)); + scrolled = true; + } + located = Some((ui.layer_id(), ui.clip_rect(), r)); + } } }); ui.add_space(5.0); @@ -563,6 +575,12 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) { }); }); let rect = resp.response.rect; + if scrolled { + crate::panels::learn::locate::scroll_once(&mut app.ui); + ctx.request_repaint(); + } else if let Some((layer, clip, r)) = located { + crate::panels::learn::locate::mark(app, ctx, layer, clip, r); + } // A tool dragged from the toolbar and dropped in the drawer leaves the toolbar. if let Some(d) = dragged.filter(|d| d.from_toolbar && over(rect)) && ctx.input(|i| i.pointer.any_released()) @@ -666,8 +684,8 @@ fn menu_id() -> egui::Id { } /// A tool's row in the drawer: icon, name and shortcut, dimmed when the tool is in the toolbar -/// already (it can't be dragged in again). Returns whether it was clicked. -fn row(ui: &mut Ui, t: &Tokens, tool: &'static ToolInfo, on: bool, dimmed: bool) -> bool { +/// already (it can't be dragged in again). Returns where it is drawn and whether it was clicked. +fn row(ui: &mut Ui, t: &Tokens, tool: &'static ToolInfo, on: bool, dimmed: bool) -> (Rect, bool) { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW), if dimmed { Sense::click() } else { Sense::click_and_drag() }); if !dimmed { resp.dnd_set_drag_payload(ToolDrag { tool: tool.id, from_toolbar: false }); @@ -711,7 +729,7 @@ fn row(ui: &mut Ui, t: &Tokens, tool: &'static ToolInfo, on: bool, dimmed: bool) egui::FontId::proportional(12.5), color, ); - resp.on_hover_text(crate::toolbar::tip(tool)).clicked() + (rect, resp.on_hover_text(crate::toolbar::tip(tool)).clicked()) } /// The tool being dragged, by the pointer (below and right of it, clear of the drop marker). diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 4ca1e345f9..5020027e87 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6690,6 +6690,8 @@ breakText Glyphs Глифи Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Кратки практически уроци за основните инструменти, групирани по умения. Изберете умение, за да видите уроците му. List Tutorials Извеждане на уроци Show Tutorial Показване на урок + Show Tool Location Показване на местоположението на инструмента + Show where this tool is Показване къде е този инструмент @msg missing `tutorial` (see learn.list) липсва `tutorial` (вижте learn.list) @msg unknown tutorial `{id}` (see learn.list) непознат урок `{id}` (вижте learn.list) diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 972414922b..5d580e787c 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6744,6 +6744,8 @@ shape Open Otvoren Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Kratki praktični tutorijali za osnovne alate, grupisani po vještinama. Odaberite vještinu da vidite njene tutorijale. List Tutorials Izlistaj tutorijale Show Tutorial Prikaži tutorijal + Show Tool Location Prikaži lokaciju alata + Show where this tool is Prikaži gdje je ovaj alat @msg missing `tutorial` (see learn.list) nedostaje `tutorial` (vidi learn.list) @msg unknown tutorial `{id}` (see learn.list) nepoznat tutorijal `{id}` (vidi learn.list) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index ebfbcd26db..dbaf24302b 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6987,6 +6987,8 @@ breakText Glyphs Glifs Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Tutorials pràctics breus de les eines essencials, agrupats per habilitat. Trieu una habilitat per veure’n els tutorials. List Tutorials Llista els tutorials Show Tutorial Mostra el tutorial + Show Tool Location Mostra la ubicació de l'eina + Show where this tool is Mostra on és aquesta eina @msg missing `tutorial` (see learn.list) falta `tutorial` (vegeu learn.list) @msg unknown tutorial `{id}` (see learn.list) tutorial desconegut `{id}` (vegeu learn.list) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f5ea30ac2b..64ea57e103 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -7006,6 +7006,8 @@ breakText Glyphs Glyfy Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Krátké praktické výukové kurzy k základním nástrojům, seřazené podle dovedností. Vyberte dovednost a zobrazí se její kurzy. List Tutorials Vypsat výukové kurzy Show Tutorial Zobrazit výukový kurz + Show Tool Location Zobrazit umístění nástroje + Show where this tool is Zobrazit, kde je tento nástroj @msg missing `tutorial` (see learn.list) chybí `tutorial` (viz learn.list) @msg unknown tutorial `{id}` (see learn.list) neznámý výukový kurz `{id}` (viz learn.list) diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index fbc690cf7f..8006a4e7b8 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6706,6 +6706,8 @@ breakText Glyphs Glyffer Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Korte praktiske vejledninger til de vigtigste værktøjer, ordnet efter færdighed. Vælg en færdighed for at se dens vejledninger. List Tutorials Vis vejledninger Show Tutorial Vis vejledning + Show Tool Location Vis værktøjets placering + Show where this tool is Vis, hvor dette værktøj er @msg missing `tutorial` (see learn.list) `tutorial` mangler (se learn.list) @msg unknown tutorial `{id}` (see learn.list) ukendt vejledning `{id}` (se learn.list) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e776bc1264..26ff0b4b64 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6601,6 +6601,8 @@ breakText Glyphs Glyphen Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Kurze praktische Lernprogramme zu den wichtigsten Werkzeugen, nach Fertigkeiten geordnet. Wählen Sie eine Fertigkeit, um ihre Lernprogramme zu sehen. List Tutorials Lernprogramme auflisten Show Tutorial Lernprogramm anzeigen + Show Tool Location Position des Werkzeugs anzeigen + Show where this tool is Zeigen, wo sich dieses Werkzeug befindet @msg missing `tutorial` (see learn.list) `tutorial` fehlt (siehe learn.list) @msg unknown tutorial `{id}` (see learn.list) unbekanntes Lernprogramm `{id}` (siehe learn.list) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 711bdeb099..69fcde7c5d 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6958,6 +6958,8 @@ breakText Glyphs Γλύφοι Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Σύντομοι πρακτικοί οδηγοί για τα βασικά εργαλεία, ομαδοποιημένοι ανά δεξιότητα. Επιλέξτε μια δεξιότητα για να δείτε τους οδηγούς της. List Tutorials Λίστα οδηγών εκμάθησης Show Tutorial Εμφάνιση οδηγού εκμάθησης + Show Tool Location Εμφάνιση θέσης εργαλείου + Show where this tool is Εμφάνιση της θέσης αυτού του εργαλείου @msg missing `tutorial` (see learn.list) λείπει το `tutorial` (δείτε learn.list) @msg unknown tutorial `{id}` (see learn.list) άγνωστος οδηγός εκμάθησης `{id}` (δείτε learn.list) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2ab24e227f..ba61749643 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6949,6 +6949,8 @@ breakText Glyphs Glifos Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Tutoriales prácticos breves sobre las herramientas esenciales, agrupados por habilidad. Elige una habilidad para ver sus tutoriales. List Tutorials Enumerar tutoriales Show Tutorial Mostrar tutorial + Show Tool Location Mostrar ubicación de la herramienta + Show where this tool is Mostrar dónde está esta herramienta @msg missing `tutorial` (see learn.list) falta `tutorial` (consulta learn.list) @msg unknown tutorial `{id}` (see learn.list) tutorial desconocido `{id}` (consulta learn.list) diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 650898ae92..4baf2bb608 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -7041,6 +7041,8 @@ breakText Glyphs Merkit Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Lyhyitä käytännön opetusohjelmia tärkeimmistä työkaluista taidoittain ryhmiteltyinä. Valitse taito nähdäksesi sen opetusohjelmat. List Tutorials Luettele opetusohjelmat Show Tutorial Näytä opetusohjelma + Show Tool Location Näytä työkalun sijainti + Show where this tool is Näytä, missä tämä työkalu on @msg missing `tutorial` (see learn.list) `tutorial` puuttuu (ks. learn.list) @msg unknown tutorial `{id}` (see learn.list) tuntematon opetusohjelma `{id}` (ks. learn.list) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 95e9c9e4c2..b7b38a26b8 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -7001,6 +7001,8 @@ breakText Glyphs Glyphes Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Courts tutoriels pratiques sur les outils essentiels, regroupés par compétence. Choisissez une compétence pour voir ses tutoriels. List Tutorials Lister les tutoriels Show Tutorial Afficher le tutoriel + Show Tool Location Afficher l'emplacement de l'outil + Show where this tool is Afficher l'emplacement de cet outil @msg missing `tutorial` (see learn.list) `tutorial` manquant (voir learn.list) @msg unknown tutorial `{id}` (see learn.list) tutoriel inconnu `{id}` (voir learn.list) diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 3d9850d4f5..32eabbf512 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8439,6 +8439,8 @@ theme Appearance अपीयरेंस Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. आवश्यक टूल के लिए छोटे व्यावहारिक ट्यूटोरियल, कौशल के अनुसार समूहित। किसी कौशल के ट्यूटोरियल देखने के लिए उसे चुनें। List Tutorials ट्यूटोरियल की सूची Show Tutorial ट्यूटोरियल दिखाएँ + Show Tool Location टूल का स्थान दिखाएँ + Show where this tool is दिखाएँ कि यह टूल कहाँ है @msg missing `tutorial` (see learn.list) लापता `tutorial` (learn.list देखें) @msg unknown tutorial `{id}` (see learn.list) अज्ञात ट्यूटोरियल `{id}` (learn.list देखें) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 948931ac13..93f71d6ef4 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6990,6 +6990,8 @@ breakText Glyphs Glifák Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Rövid gyakorlati oktatóanyagok az alapvető eszközökhöz, készségek szerint csoportosítva. Válasszon egy készséget az oktatóanyagai megtekintéséhez. List Tutorials Oktatóanyagok listázása Show Tutorial Oktatóanyag megjelenítése + Show Tool Location Eszköz helyének megjelenítése + Show where this tool is Megmutatja, hol van ez az eszköz @msg missing `tutorial` (see learn.list) hiányzik: `tutorial` (lásd: learn.list) @msg unknown tutorial `{id}` (see learn.list) ismeretlen oktatóanyag: `{id}` (lásd: learn.list) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 55e2e73bf1..c6de4d4af0 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6706,6 +6706,8 @@ breakText Glyphs Glif Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Tutorial praktik singkat untuk alat-alat utama, dikelompokkan menurut keterampilan. Pilih keterampilan untuk melihat tutorialnya. List Tutorials Daftar tutorial Show Tutorial Tampilkan tutorial + Show Tool Location Tampilkan lokasi alat + Show where this tool is Tampilkan letak alat ini @msg missing `tutorial` (see learn.list) `tutorial` tidak ada (lihat learn.list) @msg unknown tutorial `{id}` (see learn.list) tutorial `{id}` tidak dikenal (lihat learn.list) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 81dffc495f..b6398d0222 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6635,6 +6635,8 @@ breakText Glyphs Glifi Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Brevi tutorial pratici sugli strumenti essenziali, raggruppati per competenza. Scegli una competenza per vederne i tutorial. List Tutorials Elenca tutorial Show Tutorial Mostra tutorial + Show Tool Location Mostra posizione strumento + Show where this tool is Mostra dove si trova questo strumento @msg missing `tutorial` (see learn.list) manca `tutorial` (vedi learn.list) @msg unknown tutorial `{id}` (see learn.list) tutorial sconosciuto `{id}` (vedi learn.list) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9d8ec83078..f287737139 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6700,6 +6700,8 @@ breakText Glyphs 字形 Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. 基本ツールを学ぶ短い実践チュートリアルを、スキルごとにまとめています。スキルを選ぶとチュートリアルが表示されます。 List Tutorials チュートリアルの一覧 Show Tutorial チュートリアルを表示 + Show Tool Location ツールの場所を表示 + Show where this tool is このツールの場所を表示 @msg missing `tutorial` (see learn.list) `tutorial` が指定されていません (learn.list を参照) @msg unknown tutorial `{id}` (see learn.list) `{id}` は不明なチュートリアルです (learn.list を参照) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 2630b64269..4d53fa58fa 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -7120,6 +7120,8 @@ font-script Japanese იაპონური Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. მოკლე პრაქტიკული გაკვეთილები ძირითადი ხელსაწყოებისთვის, დაჯგუფებული უნარების მიხედვით. აირჩიეთ უნარი მისი გაკვეთილების სანახავად. List Tutorials გაკვეთილების სია Show Tutorial გაკვეთილის ჩვენება + Show Tool Location ხელსაწყოს მდებარეობის ჩვენება + Show where this tool is ამ ხელსაწყოს მდებარეობის ჩვენება @msg missing `tutorial` (see learn.list) აკლია `tutorial` (იხ. learn.list) @msg unknown tutorial `{id}` (see learn.list) უცნობი გაკვეთილი `{id}` (იხ. learn.list) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 30ac7dfb76..17f743929f 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6596,6 +6596,8 @@ breakText Glyphs 글리프 Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. 기본 도구를 익히는 짧은 실습 튜토리얼을 기술별로 모았습니다. 기술을 선택하면 해당 튜토리얼이 표시됩니다. List Tutorials 튜토리얼 목록 Show Tutorial 튜토리얼 표시 + Show Tool Location 도구 위치 표시 + Show where this tool is 이 도구의 위치 표시 @msg missing `tutorial` (see learn.list) `tutorial`이 없습니다(learn.list 참조) @msg unknown tutorial `{id}` (see learn.list) 알 수 없는 튜토리얼 `{id}`(learn.list 참조) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 57ca80dc20..7fcf38c2f6 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6757,6 +6757,8 @@ breakText Glyphs Glyphs Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Korte praktische tutorials voor de belangrijkste gereedschappen, gegroepeerd per vaardigheid. Kies een vaardigheid om de tutorials ervan te zien. List Tutorials Tutorials weergeven Show Tutorial Tutorial tonen + Show Tool Location Locatie van gereedschap tonen + Show where this tool is Tonen waar dit gereedschap zich bevindt @msg missing `tutorial` (see learn.list) `tutorial` ontbreekt (zie learn.list) @msg unknown tutorial `{id}` (see learn.list) onbekende tutorial `{id}` (zie learn.list) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 4094d29879..e963969a90 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6862,6 +6862,8 @@ breakText Glyphs Glify Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Krótkie praktyczne samouczki dotyczące podstawowych narzędzi, pogrupowane według umiejętności. Wybierz umiejętność, aby zobaczyć jej samouczki. List Tutorials Wyświetl samouczki Show Tutorial Pokaż samouczek + Show Tool Location Pokaż położenie narzędzia + Show where this tool is Pokaż, gdzie jest to narzędzie @msg missing `tutorial` (see learn.list) brak `tutorial` (patrz learn.list) @msg unknown tutorial `{id}` (see learn.list) nieznany samouczek `{id}` (patrz learn.list) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a367880be2..ff274fa1ef 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -7079,6 +7079,8 @@ swatch Yellow Green Verde-amarelado Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Tutoriais práticos curtos das ferramentas essenciais, agrupados por habilidade. Escolha uma habilidade para ver os tutoriais dela. List Tutorials Listar Tutoriais Show Tutorial Mostrar Tutorial + Show Tool Location Mostrar Local da Ferramenta + Show where this tool is Mostrar onde está esta ferramenta @msg missing `tutorial` (see learn.list) falta `tutorial` (veja learn.list) @msg unknown tutorial `{id}` (see learn.list) tutorial desconhecido `{id}` (veja learn.list) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e03cc555b1..53365278b4 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -7095,6 +7095,8 @@ shape Open Открытый Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Короткие практические уроки по основным инструментам, сгруппированные по навыкам. Выберите навык, чтобы увидеть его уроки. List Tutorials Перечислить уроки Show Tutorial Показать урок + Show Tool Location Показать расположение инструмента + Show where this tool is Показать, где находится этот инструмент @msg missing `tutorial` (see learn.list) отсутствует `tutorial` (см. learn.list) @msg unknown tutorial `{id}` (see learn.list) неизвестный урок `{id}` (см. learn.list) diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 83a29c8531..7430c3b912 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8484,6 +8484,8 @@ digits Hindi Hindi Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Korta praktiska självstudier för de viktigaste verktygen, ordnade efter färdighet. Välj en färdighet för att se dess självstudier. List Tutorials Lista självstudier Show Tutorial Visa självstudie + Show Tool Location Visa verktygets plats + Show where this tool is Visa var det här verktyget finns @msg missing `tutorial` (see learn.list) `tutorial` saknas (se learn.list) @msg unknown tutorial `{id}` (see learn.list) okänd självstudie `{id}` (se learn.list) diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c781b80ebb..b589bec5ce 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6962,6 +6962,8 @@ breakText Glyphs Glifler Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Temel araçlar için becerilere göre gruplanmış kısa uygulamalı öğreticiler. Öğreticilerini görmek için bir beceri seçin. List Tutorials Öğreticileri Listele Show Tutorial Öğreticiyi Göster + Show Tool Location Aracın Konumunu Göster + Show where this tool is Bu aracın nerede olduğunu göster @msg missing `tutorial` (see learn.list) `tutorial` eksik (bkz. learn.list) @msg unknown tutorial `{id}` (see learn.list) bilinmeyen öğretici `{id}` (bkz. learn.list) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 75e062627c..d0195a8e60 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6615,6 +6615,8 @@ breakText Glyphs Гліфи Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Короткі практичні уроки з основних інструментів, згруповані за навичками. Виберіть навичку, щоб побачити її уроки. List Tutorials Перелічити уроки Show Tutorial Показати урок + Show Tool Location Показати розташування інструмента + Show where this tool is Показати, де розташований цей інструмент @msg missing `tutorial` (see learn.list) відсутній `tutorial` (див. learn.list) @msg unknown tutorial `{id}` (see learn.list) невідомий урок `{id}` (див. learn.list) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 7df0386f9e..c28a90cf1d 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6616,6 +6616,8 @@ breakText Glyphs Glyph Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Các hướng dẫn thực hành ngắn cho những công cụ thiết yếu, xếp theo kỹ năng. Chọn một kỹ năng để xem các hướng dẫn của nó. List Tutorials Liệt kê hướng dẫn Show Tutorial Hiện hướng dẫn + Show Tool Location Hiện vị trí công cụ + Show where this tool is Hiện vị trí của công cụ này @msg missing `tutorial` (see learn.list) thiếu `tutorial` (xem learn.list) @msg unknown tutorial `{id}` (see learn.list) hướng dẫn không xác định `{id}` (xem learn.list) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9b593188b9..74923a758a 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6844,6 +6844,8 @@ breakText Glyphs 字形 Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. 针对基本工具的简短实操教程,按技能分组。选择一项技能即可查看其教程。 List Tutorials 列出教程 Show Tutorial 显示教程 + Show Tool Location 显示工具位置 + Show where this tool is 显示此工具的位置 @msg missing `tutorial` (see learn.list) 缺少 `tutorial` (请参阅 learn.list) @msg unknown tutorial `{id}` (see learn.list) 未知教程 `{id}` (请参阅 learn.list) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index d2245176dd..b414a930bd 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6650,6 +6650,8 @@ breakText Glyphs 字符 Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. 針對基本工具的簡短實作教學課程,依技能分組。選擇一項技能即可查看其教學課程。 List Tutorials 列出教學課程 Show Tutorial 顯示教學課程 + Show Tool Location 顯示工具位置 + Show where this tool is 顯示此工具的位置 @msg missing `tutorial` (see learn.list) 未提供 `tutorial` (請參閱 learn.list) @msg unknown tutorial `{id}` (see learn.list) 未知的教學課程 `{id}` (請參閱 learn.list) diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 390f168659..5ac3f0e448 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -653,6 +653,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{tutorial: id (see learn.list)} show the Learn panel on its Skills tab with that tutorial's numbered steps, as choosing its skill and then the tutorial does; a missing, non-string or unknown id is an error and changes nothing; returns {skill, tutorial, title, steps: [text]}", ), + ( + "learn.locateTool", + "Show Tool Location", + "", + "{tool: a tool id, as tool.select takes} mark where that tool is for 3 seconds without choosing it, as the link on a tool's name in a Learn tutorial step does: its toolbar button, a row of its collapsed group's flyout (which opens), its group's torn-off strip (which comes to the front), or, when no shown toolbar holds it, its row in the All Tools drawer (which opens); a Tools panel hidden with Window › Tools shows again; Escape or the next locate removes the mark; a missing, non-string or unknown id is an error and changes nothing; returns {tool, location: toolbar|flyout|strip|allTools|unavailable, toolbar: the name of the toolbar made with New Toolbar that holds it, or null}, with reason when Presentation Mode or Tab hides the toolbars", + ), ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), ( "window.workspace", diff --git a/crates/ui-egui/src/panels/learn.rs b/crates/ui-egui/src/panels/learn.rs index e49832c0da..cce131c7a3 100644 --- a/crates/ui-egui/src/panels/learn.rs +++ b/crates/ui-egui/src/panels/learn.rs @@ -2,10 +2,12 @@ //! hands-on tutorials grouped by skill ([`catalog::SKILLS`]). The For you tab holds the Get started //! section with the Quick skill guide card, which opens the Skills tab. The Skills tab lists each //! skill with an empty progress ring and its count of tutorials; a skill lists its tutorials, and a -//! tutorial previews its numbered steps. Back goes up one level. `learn.list` lists the tutorials, -//! `learn.show` opens one and `window.learn` shows or hides the panel. +//! tutorial previews its numbered steps. A tool's name in a step is a link that shows where the tool +//! is ([`locate`]). Back goes up one level. `learn.list` lists the tutorials, `learn.show` opens one, +//! `learn.locateTool` shows where a tool is and `window.learn` shows or hides the panel. pub mod catalog; +pub mod locate; use egui::{Sense, Stroke, Ui, vec2}; use serde_json::{Value, json}; @@ -86,7 +88,7 @@ fn skills(app: &mut VectorcraftApp, ui: &mut Ui) { app.ui.learn.skill = Some(skill.id); app.ui.learn.tutorial = None; } - steps(ui, tutorial); + steps(app, ui, tutorial); return; } if let Some(skill) = app.ui.learn.skill.and_then(catalog::skill) { @@ -113,18 +115,52 @@ fn back(ui: &mut Ui) -> bool { crate::widgets::icon_button(ui, "chevron-left", tl!("Back"), false, 22.0).clicked() } -/// A tutorial's title and its numbered steps. -fn steps(ui: &mut Ui, tutorial: &catalog::Tutorial) { +/// A tutorial's title and its numbered steps. The tool's name in a step is a blue link: a click, or +/// Enter or Space while it has the keyboard focus, shows where the tool is (`learn.locateTool`). +fn steps(app: &mut VectorcraftApp, ui: &mut Ui, tutorial: &catalog::Tutorial) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(tutorial.title).size(13.0).color(t.text_strong)); ui.add_space(4.0); + let mut locate = None; for (i, step) in tutorial.steps.iter().enumerate() { ui.horizontal_top(|ui| { ui.label(egui::RichText::new(format!("{}.", i + 1)).color(t.text_dim)); - ui.add(egui::Label::new(egui::RichText::new(step.text).color(t.text)).wrap()); + let (Some(tool), Some((before, name, after))) = (step.tool, step.tool_name()) else { + ui.add(egui::Label::new(egui::RichText::new(step.text).color(t.text)).wrap()); + return; + }; + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + if !before.is_empty() { + ui.add(egui::Label::new(egui::RichText::new(before).color(t.text)).wrap()); + } + if tool_link(ui, &t, name).clicked() { + locate = Some(tool); + } + if !after.is_empty() { + ui.add(egui::Label::new(egui::RichText::new(after).color(t.text)).wrap()); + } + }); }); ui.add_space(2.0); } + if let Some(tool) = locate { + if let Err(e) = app.run("learn.locateTool", json!({ "tool": tool })) { + app.status(e); + } + ui.ctx().request_repaint(); + } +} + +/// A tool's name in a step, drawn as a link: blue and underlined, with an outline while it has the +/// keyboard focus. +fn tool_link(ui: &mut Ui, t: &Tokens, name: &str) -> egui::Response { + let resp = ui.add(egui::Label::new(egui::RichText::new(name).color(t.selection).underline()).sense(Sense::click())); + if resp.has_focus() { + ui.painter().rect_stroke(resp.rect.expand(2.0), egui::CornerRadius::same(2), Stroke::new(1.0, t.selection), egui::StrokeKind::Outside); + } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Link, ui.is_enabled(), name)); + resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(tl!("Show where this tool is")) } /// A clickable row: `title`, and for a skill (`tutorials`) a progress ring before it and the @@ -155,12 +191,13 @@ fn row(ui: &mut Ui, title: &str, tutorials: Option<usize>) -> egui::Response { resp.on_hover_cursor(egui::CursorIcon::PointingHand) } -/// The Learn panel's UI commands: `window.learn`, `learn.list` and `learn.show`. +/// The Learn panel's UI commands: `window.learn`, `learn.list`, `learn.show` and `learn.locateTool`. pub(crate) fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> { Some(match id { "window.learn" => toggle(app, p), "learn.list" => Ok(list()), "learn.show" => open(app, p), + "learn.locateTool" => locate::locate_tool(app, p), _ => return None, }) } @@ -206,7 +243,7 @@ fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> { let (skill, tutorial) = catalog::tutorial(id).ok_or_else(|| format!("unknown tutorial `{id}` (see learn.list)"))?; crate::panel_docking::reveal_panel(app, ID)?; app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(tutorial.id) }; - let steps: Vec<&str> = tutorial.steps.iter().map(|s| s.text).collect(); + let steps: Vec<String> = tutorial.steps.iter().map(catalog::Step::plain).collect(); Ok(json!({"skill": skill.id, "tutorial": tutorial.id, "title": tutorial.title, "steps": steps})) } diff --git a/crates/ui-egui/src/panels/learn/catalog.rs b/crates/ui-egui/src/panels/learn/catalog.rs index b1ffd897d7..98f0d6f355 100644 --- a/crates/ui-egui/src/panels/learn/catalog.rs +++ b/crates/ui-egui/src/panels/learn/catalog.rs @@ -3,6 +3,8 @@ /// One step of a tutorial: what to do, and the tool it chooses or the command it runs, if any. pub struct Step { + /// The step as the panel shows it. In a step that names a tool, the tool's name is in square + /// brackets, and the panel draws it as a link that shows where the tool is. pub text: &'static str, /// A tool id ([`vectorcraft_tools::catalog::tool_info`]) the step names. pub tool: Option<&'static str>, @@ -10,6 +12,22 @@ pub struct Step { pub command: Option<&'static str>, } +impl Step { + /// The text before the tool's name, the name, and the text after it; none for a step that + /// names no tool or has no bracketed name. + pub fn tool_name(&self) -> Option<(&'static str, &'static str, &'static str)> { + self.tool?; + let (before, rest) = self.text.split_once('[')?; + let (name, after) = rest.split_once(']')?; + Some((before, name, after)) + } + + /// The text as it reads, without the brackets around the tool's name. + pub fn plain(&self) -> String { + self.text.replace(['[', ']'], "") + } +} + /// A tutorial: an id for `learn.show`, its title and its numbered steps. pub struct Tutorial { pub id: &'static str, @@ -29,7 +47,7 @@ const fn say(text: &'static str) -> Step { Step { text, tool: None, command: None } } -/// A step that uses tool `id`. +/// A step that uses tool `id`, whose name `text` holds in square brackets. const fn tool(text: &'static str, id: &'static str) -> Step { Step { text, tool: Some(id), command: None } } @@ -49,7 +67,7 @@ pub const SKILLS: &[Skill] = &[ id: "draw-and-color", title: "Draw and color a shape", steps: &[ - tool("Choose the Rectangle tool in the toolbar.", "rectangle"), + tool("Choose the [Rectangle tool] in the toolbar.", "rectangle"), say("Drag on the artboard to draw a rectangle."), say("With the rectangle still selected, click a color in the Swatches panel to fill it."), command("Click the Stroke box under the tools, then click another swatch to color the outline.", "paint.toggleActive"), @@ -59,16 +77,16 @@ pub const SKILLS: &[Skill] = &[ id: "combine-shapes", title: "Combine shapes into one", steps: &[ - tool("Choose the Ellipse tool and draw two circles that overlap.", "ellipse"), - tool("Choose the Selection tool and drag across both circles to select them.", "selection"), - tool("Choose the Shape Builder tool and drag across the parts you want to merge.", "shapeBuilder"), + tool("Choose the [Ellipse tool] and draw two circles that overlap.", "ellipse"), + tool("Choose the [Selection tool] and drag across both circles to select them.", "selection"), + tool("Choose the [Shape Builder tool] and drag across the parts you want to merge.", "shapeBuilder"), ], }, Tutorial { id: "add-headline", title: "Add a headline", steps: &[ - tool("Choose the Type tool and click on the artboard.", "type"), + tool("Choose the [Type tool] and click on the artboard.", "type"), say("Type a few words, then press Escape to finish."), say("Pick a font and a size in the Character panel."), ], @@ -83,7 +101,7 @@ pub const SKILLS: &[Skill] = &[ id: "pen-paths", title: "Draw paths with the Pen", steps: &[ - tool("Choose the Pen tool.", "pen"), + tool("Choose the [Pen tool].", "pen"), say("Click to place corner points, or drag to pull out a curve."), say("Click the first point again to close the shape, or press Enter to leave it open."), ], @@ -92,18 +110,18 @@ pub const SKILLS: &[Skill] = &[ id: "smooth-curves", title: "Draw smooth curves", steps: &[ - tool("Choose the Curvature tool.", "curvature"), + tool("Choose the [Curvature tool].", "curvature"), say("Click to place points: the path bends smoothly through each one."), - tool("Choose the Direct Selection tool and drag a point to reshape the curve.", "directSelection"), + tool("Choose the [Direct Selection tool] and drag a point to reshape the curve.", "directSelection"), ], }, Tutorial { id: "pencil-sketch", title: "Sketch with the Pencil", steps: &[ - tool("Choose the Pencil tool.", "pencil"), + tool("Choose the [Pencil tool].", "pencil"), say("Drag to draw a freehand line."), - tool("Choose the Smooth tool and drag along the line to even it out.", "smooth"), + tool("Choose the [Smooth tool] and drag along the line to even it out.", "smooth"), ], }, ], @@ -116,7 +134,7 @@ pub const SKILLS: &[Skill] = &[ id: "fill-and-stroke", title: "Fill and stroke", steps: &[ - tool("Select an object with the Selection tool.", "selection"), + tool("Select an object with the [Selection tool].", "selection"), say("Click the Fill box under the tools, then pick a color in the Color panel."), command("Click the double arrow beside the boxes to swap the fill and the stroke.", "paint.swap"), ], @@ -125,8 +143,8 @@ pub const SKILLS: &[Skill] = &[ id: "gradient", title: "Add a gradient", steps: &[ - tool("Select an object with the Selection tool.", "selection"), - tool("Choose the Gradient tool and click the object to give it a gradient.", "gradient"), + tool("Select an object with the [Selection tool].", "selection"), + tool("Choose the [Gradient tool] and click the object to give it a gradient.", "gradient"), say("Drag across the object to set the gradient's direction and length."), ], }, @@ -134,8 +152,8 @@ pub const SKILLS: &[Skill] = &[ id: "copy-colors", title: "Copy colors from other art", steps: &[ - tool("Select the object to color with the Selection tool.", "selection"), - tool("Choose the Eyedropper tool and click the art whose colors you want.", "eyedropper"), + tool("Select the object to color with the [Selection tool].", "selection"), + tool("Choose the [Eyedropper tool] and click the art whose colors you want.", "eyedropper"), ], }, ], @@ -148,8 +166,8 @@ pub const SKILLS: &[Skill] = &[ id: "area-type", title: "Flow text into a shape", steps: &[ - tool("Draw a rectangle for the text to fill.", "rectangle"), - tool("Choose the Area Type tool and click the rectangle's edge.", "areaType"), + tool("Draw a rectangle for the text to fill with the [Rectangle tool].", "rectangle"), + tool("Choose the [Area Type tool] and click the rectangle's edge.", "areaType"), say("Type or paste your text: it wraps inside the shape."), ], }, @@ -157,8 +175,8 @@ pub const SKILLS: &[Skill] = &[ id: "type-on-path", title: "Type along a path", steps: &[ - tool("Draw a curved path with the Pen tool.", "pen"), - tool("Choose the Type on a Path tool and click the path.", "typeOnPath"), + tool("Draw a curved path with the [Pen tool].", "pen"), + tool("Choose the [Type on a Path tool] and click the path.", "typeOnPath"), say("Type your text: it follows the path."), ], }, @@ -166,7 +184,7 @@ pub const SKILLS: &[Skill] = &[ id: "outline-type", title: "Turn type into shapes", steps: &[ - tool("Select a text object with the Selection tool.", "selection"), + tool("Select a text object with the [Selection tool].", "selection"), command("Choose Type › Create Outlines to turn its letters into paths.", "type.createOutlines"), ], }, diff --git a/crates/ui-egui/src/panels/learn/locate.rs b/crates/ui-egui/src/panels/learn/locate.rs new file mode 100644 index 0000000000..787e1677eb --- /dev/null +++ b/crates/ui-egui/src/panels/learn/locate.rs @@ -0,0 +1,193 @@ +//! `learn.locateTool`, which the link on a tool's name in a tutorial step runs: it marks the tool +//! where the current layout shows it, without choosing it. A tool shown on its toolbar button has +//! the button marked; a tool in a collapsed group opens the group's flyout with the tool's row +//! marked; a group torn off into a strip is raised with the tool marked in it; a tool on no shown +//! toolbar is marked in the All Tools drawer. The mark lasts [`LIFETIME`] seconds after it first +//! shows, until Escape, or until the next locate replaces it. + +use egui::{Context, CornerRadius, LayerId, Rect, Stroke, StrokeKind}; +use serde_json::{Value, json}; + +use crate::VectorcraftApp; +use crate::state::UiState; +use crate::theme::Tokens; + +/// Seconds the mark stays on screen after it first shows. +pub const LIFETIME: f64 = 3.0; +/// The width of the mark's outline. +pub const MARK_WIDTH: f32 = 2.0; +/// Passes a locate waits for its mark to draw before it is dropped: the toolbar no longer shows +/// the tool, or the flyout it opened has closed. +const UNSEEN: u8 = 3; + +/// Which control shows the located tool. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Target { + /// The Tools panel's toolbar: a button, a group's flyout or a strip torn off it. + Tools, + /// The toolbar made with New Toolbar called this: a button, a group's flyout or a strip. + Custom(String), + /// The All Tools drawer beside the Tools panel. + AllTools, +} + +/// A tool being located and how far its mark has got. +#[derive(Clone, Debug)] +pub struct Locate { + pub tool: &'static str, + pub target: Target, + /// When the mark first drew (the context's time). + since: Option<f64>, + /// The target was scrolled into view. + pub(crate) scrolled: bool, + /// The group's flyout was opened or its strip raised. + pub(crate) revealed: bool, + /// The mark drew in this pass. + drawn: bool, + /// Passes in a row in which the mark did not draw. + unseen: u8, +} + +/// The located tool when the Tools panel's toolbar (`toolbar` none) or the toolbar made with New +/// Toolbar called `toolbar` shows it. +pub(crate) fn wanted(ui: &UiState, toolbar: Option<&str>) -> Option<&'static str> { + let l = ui.learn_locate.as_ref()?; + let here = match (&l.target, toolbar) { + (Target::Tools, None) => true, + (Target::Custom(name), Some(t)) => name == t, + _ => false, + }; + here.then_some(l.tool) +} + +/// The located tool when the All Tools drawer beside the Tools panel shows it. +pub(crate) fn wanted_in_drawer(ui: &UiState) -> Option<&'static str> { + let l = ui.learn_locate.as_ref()?; + (l.target == Target::AllTools && ui.tools_drawer && ui.drawer_toolbar.is_none()).then_some(l.tool) +} + +/// Whether the target is still to be scrolled into view; true once. +pub(crate) fn scroll_once(ui: &mut UiState) -> bool { + ui.learn_locate.as_mut().is_some_and(|l| !std::mem::replace(&mut l.scrolled, true)) +} + +/// Whether the target's flyout is still to be opened or its strip raised; true once. +pub(crate) fn reveal_once(ui: &mut UiState) -> bool { + ui.learn_locate.as_mut().is_some_and(|l| !std::mem::replace(&mut l.revealed, true)) +} + +/// Mark `rect`, the located tool's control as drawn this pass on `layer`, clipped to `clip` and the +/// window. The first mark starts the [`LIFETIME`]; once it has passed, the locate ends. +pub(crate) fn mark(app: &mut VectorcraftApp, ctx: &Context, layer: LayerId, clip: Rect, rect: Rect) { + let Some(l) = app.ui.learn_locate.as_mut() else { return }; + let now = ctx.input(|i| i.time); + let since = *l.since.get_or_insert(now); + let left = LIFETIME - (now - since); + if left <= 0.0 { + app.ui.learn_locate = None; + return; + } + l.drawn = true; + ctx.request_repaint_after_secs(left as f32); + let t = Tokens::get(ctx); + let painter = ctx.layer_painter(layer).with_clip_rect(clip.intersect(ctx.content_rect())); + painter.rect_stroke(rect.shrink(1.0), CornerRadius::same(6), Stroke::new(MARK_WIDTH, t.selection), StrokeKind::Inside); +} + +/// After the toolbars and panels have drawn: Escape ends the locate, also an Escape that closed the +/// All Tools drawer or ended something else first, and so does a mark that has not drawn for +/// [`UNSEEN`] passes. +pub(crate) fn tidy(app: &mut VectorcraftApp, ctx: &Context) { + let Some(l) = app.ui.learn_locate.as_mut() else { return }; + // The raw events keep a key that a handler consumed this pass. + let escape = ctx.input(|i| i.raw.events.iter().any(|e| matches!(e, egui::Event::Key { key: egui::Key::Escape, pressed: true, .. }))); + if escape { + app.ui.learn_locate = None; + return; + } + if std::mem::take(&mut l.drawn) { + l.unseen = 0; + } else { + l.unseen += 1; + if l.unseen > UNSEEN { + app.ui.learn_locate = None; + } + } +} + +/// The reply for a tool that no toolbar can show now, with what would show it. +fn unavailable(tool: &str, reason: &str) -> Value { + json!({"tool": tool, "location": "unavailable", "toolbar": null, "reason": reason}) +} + +/// Mark `tool` on `target` from the next pass, replacing an earlier locate; the flyout or drawer an +/// earlier one opened closes. +fn start(app: &mut VectorcraftApp, tool: &'static str, target: Target) { + if let Some(old) = app.ui.learn_locate.take() { + app.ui.flyout = None; + if old.target == Target::AllTools && target != Target::AllTools && app.ui.drawer_toolbar.is_none() { + app.ui.tools_drawer = false; + } + } + app.ui.learn_locate = Some(Locate { tool, target, since: None, scrolled: false, revealed: false, drawn: false, unseen: 0 }); +} + +/// `learn.locateTool {tool}`: mark where `tool` is in the current layout → +/// `{tool, location, toolbar}`, where `location` is `toolbar` (its button), `flyout` (a row of its +/// collapsed group's flyout, which opens), `strip` (its group torn off into a strip, which comes +/// to the front), `allTools` (no shown toolbar holds it: the All Tools drawer opens) or +/// `unavailable` (with `reason`), and `toolbar` names the toolbar made with New Toolbar that +/// holds it (null for the Tools panel's). A Tools panel that Window › Tools hid shows again; the +/// tool is not chosen, and the remembered tool of its group stays. A missing, non-string or unknown +/// id is an error and changes nothing. +pub(crate) fn locate_tool(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> { + let key = "tool"; + let tool = match p.get(key) { + None | Some(Value::Null) => return Err("tool (a tool id) is required".into()), + Some(Value::String(tool)) => tool.as_str(), + Some(_) => return Err(format!("`{key}` must be a string")), + }; + let tool = vectorcraft_tools::tool_info(tool).ok_or_else(|| format!("unknown tool `{tool}`"))?.id; + if app.ui.screen_mode >= 3 { + return Ok(unavailable(tool, "Presentation Mode hides the toolbars: change the screen mode to show them")); + } + // Tab hides the toolbars made with New Toolbar until the next Tab, also once Window › Tools + // shows the Tools panel again. + let tab_hid = app.ui.tab_hid.is_some(); + if tab_hid && !app.ui.toolbar { + return Ok(unavailable(tool, "Tab has hidden the toolbars: press Tab to show them")); + } + if let Some(slot) = crate::toolbar::slots(app).into_iter().map(|(_, s)| s).find(|s| s.contains(&tool)) { + let location = if slot.len() > 1 && crate::toolbar::is_floating(app, None, slot[0]) { + "strip" + } else if crate::toolbar::shown_tool(app, &slot) == tool { + "toolbar" + } else { + "flyout" + }; + app.ui.toolbar = true; + start(app, tool, Target::Tools); + return Ok(json!({"tool": tool, "location": location, "toolbar": null})); + } + let active = app.session.tool_id(); + let custom = app.ui.custom_toolbars.iter().filter(|b| b.shown && !tab_hid).find_map(|bar| { + let slot = bar.slots.iter().find(|s| s.iter().any(|t| t == tool))?; + let tools: Vec<&'static str> = slot.iter().filter_map(|id| vectorcraft_tools::tool_info(id).map(|t| t.id)).collect(); + let first = *tools.first()?; + let location = if tools.len() > 1 && crate::toolbar::is_floating(app, Some(&bar.name), first) { + "strip" + } else if crate::custom_toolbars::shown_tool(bar, active, &tools) == Some(tool) { + "toolbar" + } else { + "flyout" + }; + Some((bar.name.clone(), location)) + }); + if let Some((name, location)) = custom { + start(app, tool, Target::Custom(name.clone())); + return Ok(json!({"tool": tool, "location": location, "toolbar": name})); + } + app.run("window.editToolbar", json!({"open": true}))?; + start(app, tool, Target::AllTools); + Ok(json!({"tool": tool, "location": "allTools", "toolbar": null})) +} diff --git a/crates/ui-egui/src/panels/learn/tests.rs b/crates/ui-egui/src/panels/learn/tests.rs index d4dbf8f00b..7014785eda 100644 --- a/crates/ui-egui/src/panels/learn/tests.rs +++ b/crates/ui-egui/src/panels/learn/tests.rs @@ -72,8 +72,20 @@ fn the_catalog_is_valid() { assert!(!tutorial.title.is_empty() && !tutorial.steps.is_empty(), "tutorial {} is empty", tutorial.id); for step in tutorial.steps { assert!(step.text.ends_with('.'), "{}: {:?}", tutorial.id, step.text); + let brackets = step.text.matches(['[', ']']).count(); if let Some(tool) = step.tool { - assert!(vectorcraft_tools::catalog::tool_info(tool).is_some(), "{}: no tool {tool}", tutorial.id); + let info = vectorcraft_tools::catalog::tool_info(tool); + assert!(info.is_some(), "{}: no tool {tool}", tutorial.id); + // The step's link names the tool it locates, once. + let name = step.tool_name().map(|(_, name, _)| name); + assert!( + brackets == 2 && name.zip(info).is_some_and(|(n, t)| n.eq_ignore_ascii_case(t.label)), + "{}: {:?}", + tutorial.id, + step.text + ); + } else { + assert_eq!(brackets, 0, "{}: {:?} links no tool", tutorial.id, step.text); } if let Some(cmd) = step.command { let known = @@ -189,7 +201,9 @@ fn a_tutorial_previews_its_steps() { assert_eq!(app.ui.learn.tutorial, Some(tutorial.id)); let text = painted_text(&mut app, show); for (i, step) in tutorial.steps.iter().enumerate() { - assert!(text.contains(&format!("{}.", i + 1)) && text.contains(step.text), "{} in {text}", step.text); + let parts = step.tool_name().map_or(vec![step.text], |(before, name, after)| vec![before, name, after]); + assert!(text.contains(&format!("{}.", i + 1)) && parts.iter().all(|p| text.contains(p)), "{} in {text}", step.text); + assert!(!text.contains('['), "no brackets: {text}"); } click(&mut app, &ctx, "Back", show); assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some(skill.id), tutorial: None }); @@ -229,3 +243,92 @@ fn learn_show_rejects_a_missing_or_unknown_tutorial() { assert!(!shown(&app)); } } + +/// The Pen tutorial's steps, as the panel shows them. +fn pen_paths(app: &mut VectorcraftApp, ui: &mut egui::Ui) { + app.ui.learn = View { tab: Tab::Skills, skill: Some("draw"), tutorial: Some("pen-paths") }; + show(app, ui); +} + +/// A step's tool name is a link; a click on it locates that tool without choosing it, and +/// `learn.show` gives the step's text without the link's brackets. +#[test] +fn a_tool_name_in_a_step_is_a_link_that_locates_the_tool() { + let mut app = app(); + let ctx = context(); + let nodes = frame(&mut app, &ctx, vec![], pen_paths); + let link = nodes.iter().find(|n| n.label().is_some_and(|l| l == "Pen tool")).expect("the Pen tool link"); + assert_eq!(link.role(), egui::accesskit::Role::Link); + assert_eq!(nodes.iter().filter(|n| n.role() == egui::accesskit::Role::Link).count(), 1, "only the step that names a tool has a link"); + click(&mut app, &ctx, "Pen tool", pen_paths); + let located = app.ui.learn_locate.as_ref().map(|l| (l.tool, l.target.clone())); + assert_eq!(located, Some(("pen", super::locate::Target::Tools)), "{}", app.ui.status); + assert_eq!(app.session.tool_id(), "selection", "the link does not choose the tool"); + let shown_tutorial = app.run("learn.show", json!({"tutorial": "pen-paths"})).unwrap(); + assert_eq!(shown_tutorial["steps"][0], "Choose the Pen tool."); +} + +/// Enter on the link, once Tab has moved the keyboard focus to it, locates the tool. +#[test] +fn enter_on_a_focused_tool_link_locates_the_tool() { + let mut app = app(); + let ctx = context(); + let link = frame(&mut app, &ctx, vec![], pen_paths) + .into_iter() + .find(|n| n.label().is_some_and(|l| l == "Pen tool")) + .and_then(|n| n.bounds()) + .expect("the Pen tool link"); + let key = |key, pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + let focused_on_link = |ctx: &egui::Context| { + ctx.memory(|m| m.focused()).and_then(|id| ctx.read_response(id)).is_some_and(|r| { + let c = r.rect.center(); + ((link.x0 + link.x1) / 2.0 - f64::from(c.x)).abs() < 1.0 && ((link.y0 + link.y1) / 2.0 - f64::from(c.y)).abs() < 1.0 + }) + }; + let mut found = false; + for _ in 0..30 { + frame(&mut app, &ctx, vec![key(egui::Key::Tab, true), key(egui::Key::Tab, false)], pen_paths); + if focused_on_link(&ctx) { + found = true; + break; + } + } + assert!(found, "Tab reaches the link"); + assert!(app.ui.learn_locate.is_none()); + frame(&mut app, &ctx, vec![key(egui::Key::Enter, true), key(egui::Key::Enter, false)], pen_paths); + frame(&mut app, &ctx, vec![], pen_paths); + assert_eq!(app.ui.learn_locate.as_ref().map(|l| l.tool), Some("pen")); +} + +/// A missing, mistyped or unknown tool id is an error and changes nothing. +#[test] +fn learn_locate_tool_rejects_a_missing_or_unknown_tool() { + let mut app = app(); + app.ui.toolbar = false; + for (params, error) in [ + (json!({}), "tool (a tool id) is required"), + (json!({"tool": null}), "tool (a tool id) is required"), + (json!({"tool": 3}), "`tool` must be a string"), + (json!({"tool": "nope"}), "unknown tool `nope`"), + ] { + assert_eq!(app.run("learn.locateTool", params).unwrap_err(), error); + assert!(app.ui.learn_locate.is_none() && !app.ui.toolbar && !app.ui.tools_drawer && app.ui.flyout.is_none()); + } +} + +/// With the toolbars hidden by Presentation Mode or by Tab, nothing can be marked: the reply says +/// so and the screen stays as it is. +#[test] +fn learn_locate_tool_is_unavailable_while_the_toolbars_are_hidden() { + let mut app = app(); + app.ui.screen_mode = 3; + let reply = app.run("learn.locateTool", json!({"tool": "pen"})).unwrap(); + assert_eq!((reply["location"].as_str(), reply["reason"].is_string()), (Some("unavailable"), true), "{reply}"); + assert!(app.ui.learn_locate.is_none()); + app.ui.screen_mode = 0; + app.run("window.dock", json!({})).unwrap(); + assert!(app.ui.tab_hid.is_some() && !app.ui.toolbar); + let reply = app.run("learn.locateTool", json!({"tool": "pen"})).unwrap(); + assert_eq!(reply["location"], "unavailable", "{reply}"); + assert!(app.ui.learn_locate.is_none() && !app.ui.toolbar && app.ui.tab_hid.is_some(), "Tab's state stays"); +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 5f22839cc5..f55db4f5e7 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -420,6 +420,10 @@ pub struct UiState { /// The Learn panel's tab, skill and tutorial, kept while the app runs. #[serde(skip)] pub learn: crate::panels::learn::View, + /// The tool whose place a Learn tutorial step's link shows (`learn.locateTool`), while its mark + /// is on screen. Not saved. + #[serde(skip)] + pub learn_locate: Option<crate::panels::learn::locate::Locate>, pub control_bar: bool, /// The Control bar is docked to the bottom of the window instead of the top /// (`window.control.dock`). @@ -1061,6 +1065,7 @@ impl Default for UiState { color_settings_scroll: false, find_font_in: String::new(), learn: Default::default(), + learn_locate: None, control_bar: false, control_bar_bottom: false, control_bar_hidden_groups: Default::default(), diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 6a90cdfac0..fc36d8cf41 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -12,6 +12,7 @@ use serde_json::{Value, json}; use vectorcraft_color::Paint; use vectorcraft_tools::{Mods, TOOL_GROUPS, ToolInfo, ToolKey, tool_info}; +use crate::panels::learn::locate as learn_locate; use crate::theme::{self, Tokens}; use crate::{VectorcraftApp, icons, widgets}; @@ -85,6 +86,17 @@ pub(crate) fn slots(app: &VectorcraftApp) -> Vec<(Option<&'static str>, Vec<&'st } } +/// The tool a slot's button shows: the active tool when the slot holds it, else the one chosen +/// last from the slot's group, else its first. +pub(crate) fn shown_tool(app: &VectorcraftApp, slot: &[&'static str]) -> &'static str { + let active = app.session.tool_id(); + if slot.contains(&active) { + return active; + } + let first = slot.first().copied().unwrap_or_default(); + app.ui.slot_tool.get(first).and_then(|id| slot.iter().copied().find(|s| s == id)).unwrap_or(first) +} + /// Remember the tool shown in the slot that contains `id`. pub fn remember(app: &mut VectorcraftApp, id: &str) { for (_, s) in slots(app) { @@ -253,6 +265,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egu // A group torn off into a floating flyout is raised instead of opening its flyout. let mut raise: Option<&'static str> = None; let labels = app.session.prefs.tool_group_labels; + let locate = learn_locate::wanted(&app.ui, None); let mut i = 0; while i < all.len() { if all[i].0.is_some() && !labels { @@ -281,12 +294,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egu ui.add_space((bounds.center().x - cols as f32 * 18.0 - ui.cursor().min.x).max(0.0)); for &k in &row { let slot = &all[k].1; - let shown_id = if slot.contains(&active) { - active.to_string() - } else { - app.ui.slot_tool.get(slot[0]).filter(|id| slot.contains(&id.as_str())).cloned().unwrap_or_else(|| slot[0].to_string()) - }; - let Some(shown) = tool_info(&shown_id).or_else(|| tool_info(slot[0])) else { continue }; + let Some(shown) = tool_info(shown_tool(app, slot)).or_else(|| tool_info(slot[0])) else { continue }; let is_active = slot.contains(&active); let (rect, resp) = ui.allocate_exact_size(vec2(36.0, PITCH - 1.0), Sense::click_and_drag()); let well = egui::Rect::from_center_size(rect.center(), vec2(30.0, 27.5)); @@ -301,6 +309,20 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egu let c = rect.center() + vec2(12.5, 10.0); ui.painter().add(egui::Shape::convex_polygon(vec![c, c + vec2(-3.5, 0.0), c + vec2(0.0, -3.5)], t.icon, Stroke::NONE)); } + // A Learn link locating a tool of this slot: the button is marked when it shows + // the tool, else the group's flyout opens on it (a strip torn off marks it). + if let Some(tool) = locate.filter(|tool| slot.contains(tool)) + && !(slot.len() > 1 && is_floating(app, None, slot[0])) + { + if !ui.clip_rect().contains_rect(rect) && learn_locate::scroll_once(&mut app.ui) { + ui.scroll_to_rect(rect, None); + ui.ctx().request_repaint(); + } else if shown.id == tool { + learn_locate::mark(app, ui.ctx(), ui.layer_id(), ui.clip_rect(), rect); + } else if learn_locate::reveal_once(&mut app.ui) { + open_flyout = Some((slot.clone(), rect)); + } + } if editing { buttons.push((k, rect)); resp.dnd_set_drag_payload(crate::edit_toolbar::ToolDrag { tool: shown.id, from_toolbar: true }); @@ -723,20 +745,27 @@ struct FlyoutInput { bar: Option<egui::Response>, /// A floating flyout's × was clicked. close: bool, + /// Where the tool a Learn link locates is drawn. + located: Option<egui::Rect>, } /// A flyout's tool rows (a floating flyout's strip of tool buttons) with the grab bar down their /// right side; a floating flyout's bar has a × at its top. The bar's id is the group's, so a drag /// that tears a flyout off carries on moving the floating flyout it becomes (`toolbar`: the toolbar -/// made with New Toolbar the group is in, none for the Tools panel's). -fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating: bool, toolbar: Option<&str>) -> FlyoutInput { +/// made with New Toolbar the group is in, none for the Tools panel's; `locate`: the tool a Learn +/// link locates, whose place it returns). +fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating: bool, toolbar: Option<&str>, locate: Option<&str>) -> FlyoutInput { let mut out = FlyoutInput::default(); let key = tools.first().map_or("", String::as_str); let bar_id = bar_id(toolbar, key); ui.spacing_mut().item_spacing = vec2(0.0, 0.0); ui.horizontal(|ui| { - let rows = if floating { ui.horizontal(|ui| tool_strip(ui, t, active, tools)) } else { ui.vertical(|ui| tool_rows(ui, t, active, tools)) }; - out.chosen = rows.inner; + let rows = if floating { + ui.horizontal(|ui| tool_strip(ui, t, active, tools, locate)) + } else { + ui.vertical(|ui| tool_rows(ui, t, active, tools, locate)) + }; + (out.chosen, out.located) = rows.inner; let (bar, _) = ui.allocate_exact_size(vec2(BAR, rows.response.rect.height()), Sense::hover()); ui.painter().rect_filled(bar, 0.0, t.tab_strip); let grip = if floating { @@ -769,9 +798,11 @@ fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating out } -/// One row per tool: icon, name and shortcut, with a dot by the active tool. Returns the tool clicked. -fn tool_rows(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option<&'static str> { +/// One row per tool: icon, name and shortcut, with a dot by the active tool. Returns the tool clicked +/// and the row of `locate`. +fn tool_rows(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], locate: Option<&str>) -> (Option<&'static str>, Option<egui::Rect>) { let mut chosen = None; + let mut located = None; let lang = crate::i18n::current(); let color = |tool: &ToolInfo| if active == tool.id { t.flyout_active } else { t.text_strong }; let rows: Vec<(&ToolInfo, RowText)> = @@ -797,8 +828,11 @@ fn tool_rows(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option< if resp.clicked() { chosen = Some(tool.id); } + if locate == Some(tool.id) { + located = Some(r); + } } - chosen + (chosen, located) } /// A flyout row's name and its shortcut, laid out. @@ -842,9 +876,10 @@ fn grab_id(toolbar: Option<&str>, key: &str) -> egui::Id { } /// One icon button per tool, the active one lit, with its name and shortcut on hover. Returns the -/// tool clicked. -fn tool_strip(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option<&'static str> { +/// tool clicked and the button of `locate`. +fn tool_strip(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], locate: Option<&str>) -> (Option<&'static str>, Option<egui::Rect>) { let mut chosen = None; + let mut located = None; for tool in tools.iter().filter_map(|id| tool_info(id)) { let (r, resp) = ui.allocate_exact_size(STRIP_BUTTON, Sense::click()); let well = r.shrink(1.0); @@ -859,8 +894,11 @@ fn tool_strip(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option if resp.on_hover_text(tip(tool)).clicked() { chosen = Some(tool.id); } + if locate == Some(tool.id) { + located = Some(r); + } } - chosen + (chosen, located) } /// The width of a floating flyout's strip of `tools`, its bar included. @@ -908,14 +946,20 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let active = app.session.tool_id(); let toolbar = ctx.data(|d| d.get_temp::<Option<String>>(flyout_toolbar_id())).flatten(); + let locate = learn_locate::wanted(&app.ui, toolbar.as_deref()).filter(|tool| tools.iter().any(|id| id == tool)); let mut input = FlyoutInput::default(); // Beside the tool, on the side with room for it: toward the canvas from a toolbar docked right. let size = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).map_or(vec2(160.0, 0.0), |r| r.size()); let x = beside(anchor, size.x, 8.0, room(app, ctx)); + // The area stays inside the window (egui's `Area::constrain`, on by default): a flyout opened + // near the bottom moves up, so every row, a located one included, shows. let resp = egui::Area::new(egui::Id::unique("tool-flyout")).order(egui::Order::Foreground).fixed_pos(pos2(x, anchor.top() - 1.0)).show(ctx, |ui| { - flyout_frame(&t, false).show(ui, |ui| input = flyout_body(ui, &t, active, &tools, false, toolbar.as_deref())); + flyout_frame(&t, false).show(ui, |ui| input = flyout_body(ui, &t, active, &tools, false, toolbar.as_deref(), locate)); }); + if let Some(rect) = input.located { + learn_locate::mark(app, ctx, resp.response.layer_id, resp.response.rect, rect); + } // Dragging the bar tears the flyout off, and so does releasing a press over it (a click, or the // end of the long press that opened the flyout): it floats where the pointer is. let at = ctx.input(|i| i.pointer.interact_pos()); @@ -998,10 +1042,19 @@ fn floating_strips(app: &mut VectorcraftApp, ctx: &egui::Context, toolbar: Optio let active = app.session.tool_id(); let mut chosen = None; let mut closed = None; + // A Learn link locating a tool of a strip raises the strip once, then marks the tool in it. + let locate = learn_locate::wanted(&app.ui, toolbar); + let raise = locate.is_some_and(|tool| strips(&app.ui, toolbar).iter().any(|f| f.tools.iter().any(|id| id == tool))) + && learn_locate::reveal_once(&mut app.ui); + let mut located = None; let Some(strips) = strips_mut(&mut app.ui, toolbar) else { return }; for (k, f) in strips.iter_mut().enumerate() { let Some(key) = f.tools.first() else { continue }; let id = strip_area(toolbar, key); + let locate = locate.filter(|tool| f.tools.iter().any(|t| t == tool)); + if raise && locate.is_some() { + raise_floating(ctx, toolbar, key); + } let since = ctx.data(|d| d.get_temp::<f64>(id)).map(|t0| now - t0); let flash = since.is_some_and(|s| (0.0..FLASH).contains(&s)); if let Some(s) = since.filter(|_| flash) { @@ -1011,9 +1064,12 @@ fn floating_strips(app: &mut VectorcraftApp, ctx: &egui::Context, toolbar: Optio let size = ctx.memory(|m| m.area_rect(id)).map_or(vec2(strip_width(&f.tools), PITCH), |r| r.size()); let pos = crate::tools_float::clamp(f.pos, size, screen); let mut input = FlyoutInput::default(); - egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| { - flyout_frame(&t, flash).show(ui, |ui| input = flyout_body(ui, &t, active, &f.tools, true, toolbar)); + let area = egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| { + flyout_frame(&t, flash).show(ui, |ui| input = flyout_body(ui, &t, active, &f.tools, true, toolbar, locate)); }); + if let Some(rect) = input.located { + located = Some((area.response.layer_id, area.response.rect, rect)); + } // Dragging the bar keeps the flyout where the pointer holds it (by an offset rather than // each frame's motion, which a just torn-off flyout's first, unseen frame would drop). let grab = grab_id(toolbar, key); @@ -1036,6 +1092,9 @@ fn floating_strips(app: &mut VectorcraftApp, ctx: &egui::Context, toolbar: Optio if let Some(k) = closed.filter(|k| *k < strips.len()) { strips.remove(k); } + if let Some((layer, clip, rect)) = located { + learn_locate::mark(app, ctx, layer, clip, rect); + } if let Some(id) = chosen { app.select_tool(id); } diff --git a/crates/ui-egui/src/toolbar/tests.rs b/crates/ui-egui/src/toolbar/tests.rs index e8e5d8f12c..8ea1928130 100644 --- a/crates/ui-egui/src/toolbar/tests.rs +++ b/crates/ui-egui/src/toolbar/tests.rs @@ -1054,3 +1054,267 @@ fn tab_skips_the_grip_and_the_tear_off_bars() { assert!(!stops.contains(&id), "Tab stops on {name}: {stops:?}"); } } + +use crate::panels::learn::locate::{LIFETIME, MARK_WIDTH}; + +/// One frame of the toolbar in a window 400 points wide and `height` tall, then what ends a Learn +/// locate (`learn.locateTool`): the marks the locate drew, and the screen-reader nodes. +fn locate_frame( + app: &mut VectorcraftApp, + ctx: &egui::Context, + time: f64, + events: Vec<Event>, + height: f32, +) -> (Vec<egui::Rect>, Vec<egui::accesskit::Node>) { + fn marks(s: &egui::Shape, color: Color32, out: &mut Vec<egui::Rect>) { + match s { + egui::Shape::Rect(r) if r.stroke.width == MARK_WIDTH && r.stroke.color == color => out.push(r.rect), + egui::Shape::Vec(v) => v.iter().for_each(|s| marks(s, color, out)), + _ => {} + } + } + let screen_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, height))); + let mut out = ctx.run_ui(egui::RawInput { time: Some(time), events, screen_rect, ..Default::default() }, |ui| { + show(app, ui); + crate::panels::learn::locate::tidy(app, ui.ctx()); + }); + out.textures_delta.clear(); + let color = Tokens::get(ctx).selection; + let mut found = vec![]; + out.shapes.iter().for_each(|c| marks(&c.shape, color, &mut found)); + let nodes = out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default(); + (found, nodes) +} + +/// A context that makes screen-reader nodes. +fn reader_context() -> egui::Context { + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + ctx +} + +/// Where the screen-reader node named after `tool`'s tool tip is drawn. +fn tool_rect(nodes: &[egui::accesskit::Node], tool: &str) -> egui::Rect { + let name = tool_info(tool).map(tip).unwrap_or_default(); + let b = nodes.iter().find(|n| n.label().is_some_and(|l| l == name)).and_then(|n| n.bounds()).unwrap_or_else(|| panic!("{name} is drawn")); + egui::Rect::from_min_max(pos2(b.x0 as f32, b.y0 as f32), pos2(b.x1 as f32, b.y1 as f32)) +} + +/// The only mark drawn, on `control`. +fn assert_marks(marks: &[egui::Rect], control: egui::Rect, what: &str) { + let [mark] = marks else { panic!("{what}: one mark, not {marks:?}") }; + let want = control.shrink(1.0); + assert!((mark.min - want.min).length() < 0.5 && (mark.max - want.max).length() < 0.5, "{what}: the mark {mark:?} is on {control:?}"); +} + +/// A Learn link's `learn.locateTool` marks a tool shown on its button, with Tools docked left or +/// right, floating or in two columns; it does not choose the tool, and the mark goes after +/// [`LIFETIME`] seconds. +#[test] +fn a_learn_link_marks_the_tools_button_wherever_tools_is() { + for (right, floating, double) in [(false, None, false), (true, None, false), (false, Some([120.0, 80.0]), false), (false, None, true)] { + let what = format!("right {right}, floating {floating:?}, double {double}"); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + (app.ui.toolbar_right, app.ui.toolbar_pos, app.ui.toolbar_double) = (right, floating, double); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 1200.0); + assert_eq!(app.run("learn.locateTool", json!({"tool": "pen"})).unwrap(), json!({"tool": "pen", "location": "toolbar", "toolbar": null})); + let (marks, nodes) = locate_frame(&mut app, &ctx, 1.0, vec![], 1200.0); + assert_marks(&marks, tool_rect(&nodes, "pen"), &what); + assert_eq!((app.session.tool_id(), app.ui.flyout), ("selection", None), "{what}: no tool chosen, no flyout"); + assert_eq!(locate_frame(&mut app, &ctx, 1.0 + LIFETIME - 0.1, vec![], 1200.0).0.len(), 1, "{what}: still marked"); + assert!(locate_frame(&mut app, &ctx, 1.0 + LIFETIME + 0.01, vec![], 1200.0).0.is_empty(), "{what}: the mark has gone"); + assert!(app.ui.learn_locate.is_none(), "{what}"); + } +} + +/// A tool in a collapsed group opens the group's flyout with its row marked, also while another +/// tool of the group shows on the button; the group keeps the tool it shows. +#[test] +fn a_learn_link_opens_a_collapsed_group_on_the_tools_row() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.ui.slot_tool.insert("pencil".into(), "pathEraser".into()); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 1200.0); + for (k, tool) in ["smooth", "pencil"].into_iter().enumerate() { + assert_eq!(app.run("learn.locateTool", json!({"tool": tool})).unwrap()["location"], "flyout", "{tool}"); + // The flyout's first frame only measures it. + locate_frame(&mut app, &ctx, 1.0 + k as f64, vec![], 1200.0); + let (marks, nodes) = locate_frame(&mut app, &ctx, 1.1 + k as f64, vec![], 1200.0); + assert!(app.ui.flyout.is_some(), "{tool}: the flyout opens"); + assert_marks(&marks, tool_rect(&nodes, tool), tool); + let row = tool_rect(&nodes, tool); + let flyout = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).expect("the flyout shows"); + assert!(flyout.contains_rect(row), "{tool}: a row of the flyout ({row:?} in {flyout:?})"); + assert_eq!(ctx.data(|d| d.get_temp::<Vec<String>>(egui::Id::unique("flyout-tools"))).map(|t| t[0].clone()).as_deref(), Some("pencil")); + } + assert_eq!(app.ui.slot_tool.get("pencil").map(String::as_str), Some("pathEraser"), "the group still shows the Path Eraser"); + assert_eq!(app.session.tool_id(), "selection"); +} + +/// A tool of a group torn off into a strip: the strip comes to the front with the tool marked in it. +#[test] +fn a_learn_link_raises_a_torn_off_group_and_marks_the_tool_in_it() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("window.floatTools", json!({"tool": "pen"})).unwrap(); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 1200.0); + assert_eq!(app.run("learn.locateTool", json!({"tool": "addAnchor"})).unwrap()["location"], "strip"); + let (marks, nodes) = locate_frame(&mut app, &ctx, 1.0, vec![], 1200.0); + assert!(ctx.data(|d| d.get_temp::<f64>(floating_area("pen"))).is_some(), "the strip was raised"); + let strip = ctx.memory(|m| m.area_rect(floating_area("pen"))).unwrap(); + let button = tool_rect(&nodes, "addAnchor"); + assert!(strip.contains_rect(button), "the strip's button: {button:?} in {strip:?}"); + assert_marks(&marks, button, "the strip"); + assert_eq!(app.ui.flyout, None); +} + +/// A tool Edit Toolbar took out of the toolbar is marked in the All Tools drawer, which opens; the +/// toolbar stays as it was. +#[test] +fn a_learn_link_marks_a_tool_no_toolbar_holds_in_all_tools() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("toolbar.removeTool", json!({"tool": "rectangle"})).unwrap(); + let before = slots(&app); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 1200.0); + assert_eq!(app.run("learn.locateTool", json!({"tool": "rectangle"})).unwrap()["location"], "allTools"); + assert!(app.ui.tools_drawer && app.ui.drawer_toolbar.is_none()); + // The drawer's first frame only measures it. + locate_frame(&mut app, &ctx, 1.0, vec![], 1200.0); + let (marks, nodes) = locate_frame(&mut app, &ctx, 1.1, vec![], 1200.0); + let drawer = ctx.memory(|m| m.area_rect(crate::edit_toolbar::drawer_id())).expect("the drawer shows"); + let row = tool_rect(&nodes, "rectangle"); + assert!(drawer.contains_rect(row), "the drawer's row: {row:?} in {drawer:?}"); + assert_marks(&marks, row, "the drawer"); + assert_eq!(slots(&app), before, "no tool added to the toolbar"); + // Escape closes the drawer first (the shortcuts consume it), and ends the locate too. + let escape = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + let screen_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 1200.0))); + let _ = ctx.run_ui(egui::RawInput { time: Some(1.2), events: vec![escape], screen_rect, ..Default::default() }, |ui| { + crate::shortcuts::handle(&mut app, ui.ctx()); + show(&mut app, ui); + crate::panels::learn::locate::tidy(&mut app, ui.ctx()); + }); + assert!(!app.ui.tools_drawer, "Escape closed the drawer"); + assert!(app.ui.learn_locate.is_none(), "and ended the locate"); +} + +/// A tool on a toolbar made with New Toolbar that the Tools panel does not hold is marked there. +#[test] +fn a_learn_link_marks_a_tool_on_a_toolbar_made_with_new_toolbar() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + app.run("toolbar.addTool", json!({"tool": "artboard", "toolbar": "Mine"})).unwrap(); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 1200.0); + assert_eq!( + app.run("learn.locateTool", json!({"tool": "artboard"})).unwrap(), + json!({"tool": "artboard", "location": "toolbar", "toolbar": "Mine"}) + ); + let (marks, nodes) = locate_frame(&mut app, &ctx, 1.0, vec![], 1200.0); + let bar = ctx.memory(|m| m.area_rect(crate::custom_toolbars::area_id("Mine"))).unwrap(); + let button = tool_rect(&nodes, "artboard"); + assert!(bar.contains_rect(button), "{button:?} in {bar:?}"); + assert_marks(&marks, button, "the toolbar Mine"); + // A tool of a group whose button shows another: the group's flyout opens on it. + app.run("toolbar.addTool", json!({"tool": "slice", "toolbar": "Mine", "slot": 0, "place": "group"})).unwrap(); + assert_eq!(app.run("learn.locateTool", json!({"tool": "slice"})).unwrap(), json!({"tool": "slice", "location": "flyout", "toolbar": "Mine"})); + locate_frame(&mut app, &ctx, 2.0, vec![], 1200.0); + let (marks, nodes) = locate_frame(&mut app, &ctx, 2.1, vec![], 1200.0); + let flyout = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).expect("the flyout shows"); + let row = tool_rect(&nodes, "slice"); + assert!(flyout.contains_rect(row), "{row:?} in {flyout:?}"); + assert_marks(&marks, row, "the flyout of the toolbar Mine"); + assert_eq!(app.session.tool_id(), "selection"); +} + +/// Tools hidden with Window › Tools shows again for a Learn link, and a short window scrolls the +/// tool into view. +#[test] +fn a_learn_link_shows_hidden_tools_and_scrolls_the_tool_into_view() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.ui.toolbar = false; + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 300.0); + assert_eq!(app.run("learn.locateTool", json!({"tool": "gradient"})).unwrap()["location"], "toolbar"); + assert!(app.ui.toolbar, "Tools shows"); + let mut drawn = (vec![], vec![]); + for k in 1..20 { + drawn = locate_frame(&mut app, &ctx, f64::from(k) * 0.1, vec![], 300.0); + } + let (marks, nodes) = drawn; + let button = tool_rect(&nodes, "gradient"); + assert!(button.top() >= 0.0 && button.bottom() <= 300.0, "scrolled into view: {button:?}"); + assert_marks(&marks, button, "scrolled"); +} + +/// A short window scrolls a tool of a toolbar made with New Toolbar into view for a Learn link. +#[test] +fn a_learn_link_scrolls_a_tool_into_view_on_a_toolbar_made_with_new_toolbar() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + let tools: Vec<&'static str> = slots(&app).into_iter().filter_map(|(_, s)| s.first().copied()).take(16).collect(); + for tool in &tools { + app.run("toolbar.addTool", json!({"tool": tool, "toolbar": "Mine"})).unwrap(); + } + let tool = *tools.last().expect("a tool"); + app.run("toolbar.removeTool", json!({"tool": tool})).unwrap(); + assert!(slots(&app).iter().all(|(_, s)| !s.contains(&tool)), "the Tools panel does not hold {tool}"); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 300.0); + let (_, nodes) = locate_frame(&mut app, &ctx, 0.05, vec![], 300.0); + let name = tool_info(tool).map(tip).unwrap_or_default(); + let shown = nodes.iter().filter(|n| n.label().is_some_and(|l| l == name)).filter_map(|n| n.bounds()).any(|b| b.y1 <= 300.0); + assert!(!shown, "{tool} starts outside the window"); + assert_eq!(app.run("learn.locateTool", json!({"tool": tool})).unwrap(), json!({"tool": tool, "location": "toolbar", "toolbar": "Mine"})); + let mut drawn = (vec![], vec![]); + for k in 1..20 { + drawn = locate_frame(&mut app, &ctx, f64::from(k) * 0.1, vec![], 300.0); + } + let (marks, nodes) = drawn; + let button = tool_rect(&nodes, tool); + let bar = ctx.memory(|m| m.area_rect(crate::custom_toolbars::area_id("Mine"))).unwrap(); + assert!(bar.contains_rect(button), "{button:?} in {bar:?}"); + assert!(button.top() >= 0.0 && button.bottom() <= 300.0, "scrolled into view: {button:?}"); + assert_marks(&marks, button, "scrolled on the toolbar Mine"); + assert_eq!(app.session.tool_id(), "selection"); +} + +/// In a short window, the flyout of the last group stays inside the window, so the located row's +/// mark shows. +#[test] +fn a_learn_link_in_a_short_window_marks_a_flyout_row_inside_the_window() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = reader_context(); + let slot = slots(&app).into_iter().map(|(_, s)| s).rfind(|s| s.len() > 1).expect("a group"); + let tool = *slot.last().expect("a tool"); + locate_frame(&mut app, &ctx, 0.0, vec![], 300.0); + assert_eq!(app.run("learn.locateTool", json!({"tool": tool})).unwrap()["location"], "flyout", "{tool}"); + let mut drawn = (vec![], vec![]); + for k in 1..20 { + drawn = locate_frame(&mut app, &ctx, f64::from(k) * 0.1, vec![], 300.0); + } + let (marks, nodes) = drawn; + let row = tool_rect(&nodes, tool); + assert!(row.top() >= 0.0 && row.bottom() <= 300.0, "{tool}: the row is inside the window: {row:?}"); + assert_marks(&marks, row, tool); +} + +/// A second Learn link moves the mark; Escape removes it. +#[test] +fn a_second_learn_link_replaces_the_mark_and_escape_removes_it() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 1200.0); + app.run("learn.locateTool", json!({"tool": "pen"})).unwrap(); + let (marks, nodes) = locate_frame(&mut app, &ctx, 0.5, vec![], 1200.0); + assert_marks(&marks, tool_rect(&nodes, "pen"), "pen"); + app.run("learn.locateTool", json!({"tool": "curvature"})).unwrap(); + let (marks, nodes) = locate_frame(&mut app, &ctx, 1.0, vec![], 1200.0); + assert_marks(&marks, tool_rect(&nodes, "curvature"), "the second link"); + let escape = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + locate_frame(&mut app, &ctx, 1.5, vec![escape], 1200.0); + assert!(app.ui.learn_locate.is_none()); + assert!(locate_frame(&mut app, &ctx, 2.0, vec![], 1200.0).0.is_empty()); +} diff --git a/docs/mcp.md b/docs/mcp.md index 62a4212472..4e9c6ed450 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2559,6 +2559,24 @@ and changes nothing. Behavior reference: {"name":"invoke_menu","arguments":{"command":"learn.show","params":{"tutorial":"pen-paths"}}} ``` +A step that names a tool shows the tool's name as a blue link. A click on it, or Enter or Space while it has the +keyboard focus, runs `learn.locateTool {tool}`, which marks where the tool is in the current layout without choosing +it: its toolbar button, or the tool's row in its group's flyout, which opens when the button shows another tool of the +group. A group torn off into a strip comes to the front with the tool marked in it. A tool that no shown toolbar holds +is marked in the All Tools drawer, which opens; the tool is not added to a toolbar. A Tools panel hidden with +Window › Tools shows again. A target below the window's bottom scrolls into view. The mark lasts 3 seconds after it +first shows; Escape or the next locate removes it. The reply is `{tool, location, toolbar}`: `location` is `toolbar`, +`flyout`, `strip`, `allTools` or `unavailable`, and `toolbar` names the toolbar made with New Toolbar that holds the +tool (null for the Tools panel's). While Presentation Mode or Tab hides the toolbars, the reply is `unavailable` with +a `reason`, and nothing changes. A missing, non-string or unknown `tool` is an error and changes nothing. The mark, +its 3 seconds, the flyout, strip and All Tools behavior and the hidden-toolbar replies are VectorCraft's own rules; +the help page (https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html) +describes selecting a tool's name in a step to find the tool. + +```json +{"name":"invoke_menu","arguments":{"command":"learn.locateTool","params":{"tool":"smooth"}}} +``` + ## Ruler guides Each ruler guide is an object of a layer or sublayer, directly in it and above its art, and takes an id from the diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5ec97e34c1..eb4c3bef2a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -838,6 +838,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Object › Transform › Reset Bounding Box squares the box of every object inside the selected ones as well: the children of groups, compound paths, clip groups, blends, envelopes, repeats and intertwines, and the art of linked opacity masks. Ungroup and the release commands then bring back boxes square to the page (VectorCraft's own behavior; `bboxrotate::reset_bounding_box_squares_the_objects_inside_a_group`, `reset_bounding_box_squares_the_turned_members_of_a_square_group`, `reset_bounding_box_then_release_clipping_mask` and the other `reset_bounding_box_then_*` tests, `reset_bounding_box_leaves_the_art_of_an_unlinked_opacity_mask`). - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). - The Learn panel (Window › Learn, or Explore tutorials beside the search icon on the app bar) has a For you tab with a Get started section and a Quick skill guide card, and a Skills tab that lists VectorCraft's own skills with their tutorial counts and empty progress rings; a skill lists its tutorials, a tutorial shows its numbered steps, and Back goes up a level; `learn.list` and `learn.show {tutorial}` script it ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`). + - A tool's name in a Learn tutorial step is a blue link that shows where the tool is without choosing it (`learn.locateTool {tool}`): its toolbar button is marked, a collapsed group's flyout opens with the tool's row marked, a torn-off strip comes to the front, and a tool no shown toolbar holds is marked in the All Tools drawer. The mark, its 3 seconds and the hidden-toolbar cases are VectorCraft's own rules ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`, the `a_learn_link_*` tests in `toolbar::tests`). - The errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add Spine Point and Delete Spine Point) show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages and in Bulgarian (`blend_spine_param_errors_are_translated`). - The errors of the parameter checks that many commands share show in the status bar in the interface language: a value that is not a number where a number goes (`object.move` with `"dx": "60 mm"`, and `object.rotate`, `object.setBounds` and the other commands that call `check_numbers`), a missing number or index, `ids` that are not a list of object ids, an object id that is not a non-negative integer or names no object, and a date that is not Unix seconds. Each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch (`shared_param_check_errors_are_translated`). - Export commands run through MCP, the control channel or the CLI refuse a key that no format takes, and a command that writes one format (`document.exportPdf`, `document.exportEps`, `document.exportDxf`) or takes one format's options (`document.pdfSettings`, `pdf.preset.save`) refuses a key that format doesn't take. An unknown key inside `marks`, `bleed`, `compression`, `output`, `advanced`, `security` or `flattener` is refused by its path. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 81cfe1e0e1..88c438775b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -374,7 +374,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po |---|---|---| | Effect > Warp Options: style previews, orientation choices and percent controls | done | #1760; focused input, mixed-style and history checks passed; original captures reviewed. No geometry or optional Properties changes. | | Command search: Up and Down move through the results, Enter runs the highlighted one | done | #1798 (#1757) | -| Learn panel: Window › Learn and Explore tutorials on the app bar open it; For you holds a Quick skill guide card, Skills lists VectorCraft's own skills with tutorial counts and empty progress rings, a tutorial shows its numbered steps, Back goes up a level | done | [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `learn.list`, `learn.show`; `panels::learn::tests` | +| Learn panel: Window › Learn and Explore tutorials on the app bar open it; For you holds a Quick skill guide card, Skills lists VectorCraft's own skills with tutorial counts and empty progress rings, a tutorial shows its numbered steps, Back goes up a level; a tool's name in a step is a link that marks the tool where the toolbars show it (its button, its group's flyout, a torn-off strip, a toolbar made with New Toolbar or the All Tools drawer) without choosing it | done | [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `learn.list`, `learn.show`, `learn.locateTool`; the mark's look and 3-second lifetime are VectorCraft's own rules; `panels::learn::tests`, `toolbar::tests` | | Pathfinder panel: a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled | done | #1642 (#1635) | | Selected Grid Repeat shows row, column and corner handles and spacing sliders; bounding-box handles take priority where they meet | done | #2691, `tools/src/repeat_grid.rs`; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html); drags preview and commit `object.repeat.options` as one undo step, Escape cancels; Radial Repeat has no handles yet | | Grid Repeat Options (Object › Repeat › Options…) has a Preview checkbox: the repeat on the canvas follows the fields while the dialog is open, Cancel puts the original back without a history step, OK keeps the previewed values as one undo step | done | #2651; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html) lists the Preview option for the grid options only, so Radial and Mirror Repeat Options have none; `dialogs/command.rs` tests | From 40f4c920e732c4649dc198aed75672612b078ac6 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 10:26:11 -0800 Subject: [PATCH 68/92] Type: Bunri-Kinshi keeps inseparable character pairs on one line Paragraph panel menu > Bunri-Kinshi (with the East Asian options) and text.setFormat {bunriKinshi} turn it on per paragraph. A line then never breaks inside two em dashes, two horizontal ellipses, two two-dot leaders or the two halves of a vertical kana repeat mark, the pairs W3C JLREQ lists for class cl-08 (https://www.w3.org/TR/jlreq/#cl-08), nor inside a chain of em dashes. Both composers, vertical type, Kinsoku Shori Type, hyphenation and No Break follow it, and a pair wider than the line stays whole on it. Justification and letter spacing add no space inside a pair. The setting works only with a Kinsoku Set: with Kinsoku Set None the paragraph keeps the value without effect and the menu item is unavailable. It is off by default, saved with the paragraph only when on, carried by paragraph styles (Paragraph Style Options > East Asian Composition Settings), and turned off by Reset Panel. A value that is not true or false is refused. The .ai editing data has no field for it, so saving reports a paragraph with it on and a pair in it as left out. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/doc/src/text/para_style.rs | 10 +++ crates/engine/src/cmd/panelcmds.rs | 7 +- crates/engine/src/cmd/textstyles.rs | 1 + crates/engine/src/cmd/typecmd/area_tests.rs | 53 +++++++++++ crates/engine/src/tests/textedit.rs | 1 + crates/eps/src/ai_editing/tests.rs | 36 ++++++++ crates/eps/src/ai_editing/writer/texts.rs | 4 + crates/text/src/justify.rs | 5 +- crates/text/src/layout.rs | 2 + crates/text/src/layout/breaking.rs | 19 ++-- crates/text/src/layout/flow.rs | 24 +++-- crates/text/src/layout/wrapping_tests.rs | 87 +++++++++++++++++++ crates/text/src/lib.rs | 2 +- crates/text/src/shape.rs | 35 ++++++++ crates/text/src/tests.rs | 81 +++++++++++++++++ .../ui-egui/src/dialogs/para_style_options.rs | 21 +++-- .../src/dialogs/para_style_options/tests.rs | 33 ++++++- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/panels/paragraph.rs | 25 +++++- crates/ui-egui/src/panels/paragraph/tests.rs | 42 +++++++++ docs/mcp.md | 21 +++++ docs/target-app-parity.md | 3 +- 48 files changed, 510 insertions(+), 29 deletions(-) diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs index bb3725072e..a2c2a66498 100644 --- a/crates/doc/src/text/para_style.rs +++ b/crates/doc/src/text/para_style.rs @@ -237,6 +237,10 @@ pub struct ParaStyle { /// Kinsoku Shori Type (Paragraph panel menu): how a line keeps those characters off its start /// and end, by pushing them in onto the line or out to the next one. No effect without kinsoku. pub kinsoku_type: KinsokuShoriType, + /// Bunri-Kinshi (Paragraph panel menu): a line never breaks inside an inseparable pair (two em + /// dashes, two ellipses, two two-dot leaders, a vertical kana repeat mark's two halves). Kept + /// with kinsoku None, where it has no effect. Off by default. + pub bunri_kinshi: bool, /// Roman Hanging Punctuation (Paragraph panel menu): quotation marks, hyphens, full stops and /// commas starting or ending a line hang outside the margin, and asterisks, tildes, ellipses, /// dashes, colons and semicolons hang by half. East Asian punctuation hangs by burasagari. @@ -339,6 +343,8 @@ struct ParaStyleFile { #[serde(default, skip_serializing_if = "crate::skip::is_default")] kinsoku_type: KinsokuShoriType, #[serde(default, skip_serializing_if = "crate::skip::is_default")] + bunri_kinshi: bool, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] roman_hanging: bool, #[serde(default, skip_serializing_if = "crate::skip::is_default")] kashida: Kashida, @@ -375,6 +381,7 @@ impl From<ParaStyle> for ParaStyleFile { burasagari, kinsoku, kinsoku_type, + bunri_kinshi, roman_hanging, kashida, korean_line_break_anywhere, @@ -401,6 +408,7 @@ impl From<ParaStyle> for ParaStyleFile { burasagari, kinsoku, kinsoku_type, + bunri_kinshi, roman_hanging, kashida, korean_line_break_anywhere, @@ -431,6 +439,7 @@ impl From<ParaStyleFile> for ParaStyle { burasagari, kinsoku, kinsoku_type, + bunri_kinshi, roman_hanging, kashida, korean_line_break_anywhere, @@ -456,6 +465,7 @@ impl From<ParaStyleFile> for ParaStyle { burasagari, kinsoku, kinsoku_type, + bunri_kinshi, roman_hanging, kashida, korean_line_break_anywhere, diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 83a0655284..325be0dd3c 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec<CommandSpec> { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), bunriKinshi?: bool (Paragraph panel menu › Bunri-Kinshi: a line never breaks inside an inseparable pair, two em dashes, two horizontal ellipses, two two-dot leaders or the two halves of a vertical kana repeat mark, and justification adds no space inside one; kept but without effect with kinsoku \"none\"; off by default), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment)", has_doc, set_format ), @@ -483,6 +483,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { "hyphenateCapitalized", "romanHangingPunctuation", "koreanLineBreakAnywhere", + "bunriKinshi", ], C, )?; @@ -536,6 +537,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { "mojikumi", "kinsoku", "kinsokuType", + "bunriKinshi", "direction", "leadingModel", "charAlign", @@ -869,6 +871,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { if let Some(v) = kinsoku_type { para.kinsoku_type = v; } + if let Some(v) = flag("bunriKinshi") { + para.bunri_kinshi = v; + } if let Some(v) = direction { para.direction = v; } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 40d8af22ad..13c2e9e05b 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -243,6 +243,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map<String, Value> { ("burasagari", json!(v.burasagari)), ("kinsoku", json!(v.kinsoku)), ("kinsoku_type", json!(v.kinsoku_type)), + ("bunri_kinshi", json!(v.bunri_kinshi)), ("roman_hanging", json!(v.roman_hanging)), ("kashida", json!(v.kashida)), ("korean_line_break_anywhere", json!(v.korean_line_break_anywhere)), diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 5545e1be94..2c7d61bb0a 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -440,6 +440,59 @@ fn korean_line_break_anywhere_is_set_per_paragraph_and_saved_only_when_off() { assert!(!text(&s).para_at(0).korean_line_break_anywhere && !text(&s).para_at(1).korean_line_break_anywhere); } +/// Paragraph panel menu › Bunri-Kinshi: off for new type and for a paragraph saved before it, and +/// saved only when on; `text.setFormat {bunriKinshi}` sets it per paragraph in one undo step and +/// refuses a value that is not true or false, kinsoku None keeps it, and paragraph styles carry it. +#[test] +fn bunri_kinshi_is_set_per_paragraph_and_saved_only_when_on() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": "あ——い\nう……え"})).unwrap()["id"].as_u64().unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + assert!(!text(&s).para_at(0).bunri_kinshi, "new type has it off"); + assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("bunri_kinshi").is_none()); + let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap(); + assert!(!old.bunri_kinshi, "saved before the option: off"); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + // Only the second paragraph. + let second = "あ——い\n".len(); + s.execute("text.setFormat", &json!({"bunriKinshi": true, "start": second, "end": second})).unwrap(); + assert!(!text(&s).para_at(0).bunri_kinshi && text(&s).para_at(1).bunri_kinshi); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + let saved = serde_json::to_value(text(&s).para_at(1)).unwrap(); + assert_eq!(saved["bunri_kinshi"], true); + let back: vectorcraft_doc::ParaStyle = serde_json::from_value(saved).unwrap(); + assert!(back.bunri_kinshi); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert!(!back.para_at(0).bunri_kinshi && back.para_at(1).bunri_kinshi, "the file keeps it per paragraph"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!text(&s).para_at(1).bunri_kinshi); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(text(&s).para_at(1).bunri_kinshi); + for bad in [json!("yes"), json!(1)] { + assert!(s.execute("text.setFormat", &json!({"ids": [id], "bunriKinshi": bad, "kinsoku": "none"})).is_err(), "{bad}"); + } + assert!(s.execute("text.setRangeStyle", &json!({"id": id, "bunriKinshi": false})).is_err()); + assert!(text(&s).para_at(1).bunri_kinshi && text(&s).para_at(1).kinsoku != vectorcraft_doc::Kinsoku::None, "a refused call changes nothing"); + // Kinsoku None keeps the setting, without effect, and turning kinsoku back on restores it. + s.execute("text.setFormat", &json!({"ids": [id], "kinsoku": "none"})).unwrap(); + assert!(text(&s).para_at(1).bunri_kinshi); + s.execute("text.setFormat", &json!({"ids": [id], "kinsoku": "hard"})).unwrap(); + assert!(text(&s).para_at(1).bunri_kinshi); + // A paragraph style carries it. + s.execute("paraStyle.new", &json!({"name": "Pairs", "attrs": {"bunri_kinshi": true}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Pairs", "id": id})).unwrap(); + assert!(text(&s).para_at(0).bunri_kinshi && text(&s).para_at(1).bunri_kinshi); + s.execute("paraStyle.new", &json!({"name": "Split", "attrs": {"bunri_kinshi": false}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Split", "id": id})).unwrap(); + assert!(!text(&s).para_at(0).bunri_kinshi && !text(&s).para_at(1).bunri_kinshi); +} + /// Paragraph panel › Insert Kashidas: `text.setFormat {kashida}` sets it per paragraph in one /// undo step, the paragraph is saved with it only when it is not None and opens with it, an /// unknown value changes nothing, and paragraph styles carry it. diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 46d41adaea..030bd93bfe 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -524,6 +524,7 @@ fn type_commands_refuse_a_number_or_boolean_given_as_text() { ("text.setFormat", json!({"ids": [id.0], "hyphenateCapitalized": 0}), "hyphenateCapitalized"), ("text.setFormat", json!({"ids": [id.0], "romanHangingPunctuation": "true"}), "romanHangingPunctuation"), ("text.setFormat", json!({"ids": [id.0], "koreanLineBreakAnywhere": 1}), "koreanLineBreakAnywhere"), + ("text.setFormat", json!({"ids": [id.0], "bunriKinshi": "on"}), "bunriKinshi"), ("text.getRange", json!({"id": id.0, "effective": "true"}), "effective"), ("type.opticalMarginAlignment", json!({"on": "true"}), "on"), ("type.systemLayout", json!({"on": "true"}), "on"), diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 0ccfc23ca8..7d956d48b0 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -3815,6 +3815,42 @@ fn korean_line_break_anywhere_is_reported_only_for_korean_paragraphs() { } } +/// The text document has no Bunri-Kinshi: a paragraph with it on and an inseparable pair in it is +/// reported as left out, and comes back off; with it off, or with no pair, nothing is reported. +#[test] +fn bunri_kinshi_is_reported_only_for_paragraphs_with_a_pair() { + use vectorcraft_doc::TextObject; + for (text, on, reported) in [ + ("Wait——now", true, true), + ("Wait——now", false, false), + ("Wait—now", true, false), + ("Wait……now", true, true), + ("Wait‥‥now", true, true), + ("あ〳〵い", true, true), + ("あ〴〵い", true, true), + ("あ〵〳い", true, false), + ] { + let mut t = TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), text, style(14.0, Color::BLACK)); + t.para.bunri_kinshi = on; + let mut doc = Document::new(200.0, 200.0); + let layer = doc.layers[0].id; + let n = text_node(&mut doc, t); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(!out.warnings.is_empty(), reported, "{text} {on}: {:?}", out.warnings); + let mut all = vec![]; + paths(&read_back(&doc).layers, &mut all); + let paras: Vec<bool> = all + .iter() + .filter_map(|n| match &n.kind { + NodeKind::Text(t) => Some(t.para_at(0).bunri_kinshi), + _ => None, + }) + .collect(); + assert_eq!(paras, [false], "{text} {on}"); + } +} + #[test] fn type_is_written_again_as_the_same_bytes() { let doc = typed(); diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs index 1d791afadf..1e47ca1564 100644 --- a/crates/eps/src/ai_editing/writer/texts.rs +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -387,6 +387,7 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { } let hangul = text.chars().any(|c| matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7FF}' | '\u{FFA0}'..='\u{FFDC}')); + let pairs = text.chars().zip(text.chars().skip(1)).any(|(a, b)| vectorcraft_text::inseparable(a, b)); let held = ParaStyle { justify: p.justify, left_indent: p.left_indent, @@ -403,6 +404,9 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { // The text document has no Korean Line Break Anywhere: it reads back off, which // changes only the lines of a paragraph with Hangul in it. korean_line_break_anywhere: p.korean_line_break_anywhere && !hangul, + // Nor Bunri-Kinshi: it reads back off, which changes only the lines of a paragraph + // with an inseparable pair in it. + bunri_kinshi: p.bunri_kinshi && !pairs, ..ParaStyle::default() }; *p != held diff --git a/crates/text/src/justify.rs b/crates/text/src/justify.rs index 3a4f561fb7..cb136a8a24 100644 --- a/crates/text/src/justify.rs +++ b/crates/text/src/justify.rs @@ -100,9 +100,10 @@ fn continues_word(g: &SGlyph, next: Option<&SGlyph>) -> bool { /// A glyph letter spacing follows: a visible glyph followed by another character in the same word /// (`next` is the next glyph that is not a format control, or a zero-width space). The glyphs of -/// one shaped cluster (a letter and its combining marks) take none between them. +/// one shaped cluster (a letter and its combining marks) take none between them, and neither do the +/// two halves of an inseparable pair that Bunri-Kinshi keeps together. fn letter_gap(g: &SGlyph, next: Option<&SGlyph>) -> bool { - gap_owner(g) && continues_word(g, next) + gap_owner(g) && !g.keeps_next && continues_word(g, next) } /// The width of a word space in `g`'s font and size: what letter spacing is a share of. diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 6e02df73cd..d2685ac211 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -688,6 +688,7 @@ mod emit_kashida_mark_last { inline: None, word_end: false, keeps_word: false, + keeps_next: false, lead: 0.0, proportional: false, vpal: None, @@ -750,6 +751,7 @@ mod emit_kashida_mark_last { inline: None, word_end: false, keeps_word: false, + keeps_next: false, lead: 0.0, proportional: false, vpal: None, diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index 0ac57e0833..30a498257e 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -347,10 +347,11 @@ pub(super) fn break_line( while g.get(cluster_end).is_some_and(|next| next.byte == gl.byte && (next.len == 0) == (gl.len == 0)) { cluster_end += 1; } - // No Break: the marked characters stay on one line, including across a space inside the run. - if gl.no_break && gl.ch != '\u{2028}' { + // No Break: the marked characters stay on one line, including across a space inside the run; + // Bunri-Kinshi keeps an inseparable pair, or a chain of them, on one line too. + if gl.ch != '\u{2028}' { while let Some(n) = g.get(cluster_end) { - if !n.no_break || n.ch == '\u{2028}' { + if !splits_no_break(g, cluster_end) || n.ch == '\u{2028}' { break; } let b = n.byte; @@ -550,9 +551,10 @@ fn may_end_after(g: &[SGlyph], end: usize, kinsoku: Kinsoku) -> bool { last.break_after() && last.len > 0 && !last.is_soft_hyphen() && kinsoku_allows(g, e - 1, kinsoku) && !splits_no_break(g, e) } -/// Would a line ending at glyph index `end` split a No Break run? +/// Would a line ending at glyph index `end` split a No Break run, or an inseparable pair that +/// Bunri-Kinshi keeps together ([`SGlyph::keeps_next`])? fn splits_no_break(g: &[SGlyph], end: usize) -> bool { - end > 0 && g.get(end - 1).is_some_and(|a| a.no_break) && g.get(end).is_some_and(|b| b.no_break) + end > 0 && g.get(end - 1).is_some_and(|a| (a.no_break && g.get(end).is_some_and(|b| b.no_break)) || (a.keeps_next && g.get(end).is_some())) } /// Does kinsoku allow a line break after glyph `j`? Not after an opening bracket, nor before a @@ -622,6 +624,7 @@ fn push_in( k >= g.len() || (g[k].byte != g[k - 1].byte && kinsoku_allows(g, k - 1, kinsoku) + && !splits_no_break(g, k) && (g[k].is_space() || (g[k - 1].break_after() && !g[k - 1].is_soft_hyphen()))) }; let (mut end, mut x) = (cluster_end, x); @@ -686,12 +689,14 @@ fn push_in( /// May a push-in close the gap after glyph `a`? A gap between two characters, at least one of them /// CJK, outside a tate-chu-yoko block, a list marker and a shaped cluster, and not next to a space, -/// a tab or an inline graphic before it. Zero-width format controls take no gap: the gap is the -/// one between the visible characters on either side, before `end`, as the layout justifies it. +/// a tab or an inline graphic before it, nor inside an inseparable pair that Bunri-Kinshi keeps +/// together. Zero-width format controls take no gap: the gap is the one between the visible +/// characters on either side, before `end`, as the layout justifies it. fn closes(g: &[SGlyph], a: usize, end: usize) -> bool { let Some(p) = g.get(a).filter(|p| !is_format_control(p.ch)) else { return false }; let Some(n) = g.get(a + 1..end).and_then(|rest| rest.iter().find(|n| !is_format_control(n.ch))) else { return false }; p.len > 0 + && !p.keeps_next && p.byte != n.byte && p.inline.is_none() && !p.is_space() diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 79b8f4326b..95ee27184f 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -11,7 +11,7 @@ use super::{ fit_line, hyphen_glyph, is_cjk, is_word_space, kashida, mark_word_ends, no_line_end, no_line_start, style_metrics, }; use crate::justify::is_format_control; -use crate::shape::keep_korean_words; +use crate::shape::{keep_inseparable_pairs, keep_korean_words}; /// Does paragraph `para` push characters in (Kinsoku Shori Type Push In First or Push Out First, /// with kinsoku on)? @@ -230,6 +230,10 @@ fn prepare_paragraph<'a, 'text>( if !para.korean_line_break_anywhere { keep_korean_words(&mut sg); } + // Bunri-Kinshi keeps inseparable pairs on one line, while kinsoku is on. + if para.bunri_kinshi && para.kinsoku != Kinsoku::None { + keep_inseparable_pairs(text, &mut sg); + } // Japanese composition: consecutive punctuation shares one half-em space. let mut compressed = if para.mojikumi == Mojikumi::LineEndHalf { compress_punctuation(&mut sg) } else { vec![] }; // …and a quarter em between Japanese and Latin text. @@ -736,16 +740,24 @@ fn gap_pair<'a>(glyph: usize, held: &[usize], after: &[Option<usize>], index: us fn is_cjk_gap(glyph: usize, held: &[usize], after: &[Option<usize>], index: usize, sg: &[SGlyph]) -> bool { gap_pair(glyph, held, after, index, sg).is_some_and(|(a, b)| { a.byte != b.byte && !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch)) + }) && !in_pair(glyph, index, after, sg) +} + +// Bunri-Kinshi: are glyph `glyph` and the glyph drawn after it the two halves of an inseparable +// pair? Justification adds no space between them. +fn in_pair(glyph: usize, index: usize, after: &[Option<usize>], sg: &[SGlyph]) -> bool { + drawn_after(index, glyph, after).is_some_and(|next| { + (next == glyph + 1 && sg.get(glyph).is_some_and(|a| a.keeps_next)) || (glyph == next + 1 && sg.get(next).is_some_and(|b| b.keeps_next)) }) } // Single Word Justification's Full Justify spreads a line's leftover room over the gaps between -// its drawn glyphs: not after a held glyph, nor inside a tate-chu-yoko block (one cell) or a -// shaped cluster, whose glyphs (a letter and its combining marks, a conjunct and its matra) stay -// together. On a line that ends in a drawn hyphen, the gap before the hyphen counts too: it follows -// the line's last visible glyph. +// its drawn glyphs: not after a held glyph, nor inside a tate-chu-yoko block (one cell), a shaped +// cluster, whose glyphs (a letter and its combining marks, a conjunct and its matra) stay +// together, or an inseparable pair. On a line that ends in a drawn hyphen, the gap before the +// hyphen counts too: it follows the line's last visible glyph. fn is_letter_gap(glyph: usize, held: &[usize], after: &[Option<usize>], index: usize, sg: &[SGlyph], hyphen: bool) -> bool { - if held.contains(&glyph) || sg.get(glyph).is_none_or(|a| is_format_control(a.ch)) { + if held.contains(&glyph) || sg.get(glyph).is_none_or(|a| is_format_control(a.ch)) || in_pair(glyph, index, after, sg) { return false; } match gap_pair(glyph, held, after, index, sg) { diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index dde3593b9d..7127cacba3 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -34,6 +34,7 @@ fn synthetic_glyphs(specs: &[(usize, usize, f64, char)]) -> Vec<SGlyph> { inline: None, word_end: false, keeps_word: false, + keeps_next: false, lead: 0.0, proportional: false, vpal: None, @@ -627,3 +628,89 @@ fn korean_words_take_no_hyphen() { assert_eq!(hyphens(true), 1, "on, the word's Latin letters hyphenate as before"); }); } + +/// The glyphs of `text`, each 10 pt wide, with its inseparable pairs kept together when `bunri` +/// (Bunri-Kinshi on). +fn pairs(text: &str, bunri: bool) -> Vec<SGlyph> { + let mut glyphs = full_width(text); + if bunri { + crate::shape::keep_inseparable_pairs(text, &mut glyphs); + } + glyphs +} + +/// Bunri-Kinshi: both composers never break inside two em dashes, two ellipses, two two-dot +/// leaders or a vertical kana repeat mark, nor inside a chain of em dashes; off, the line may break +/// between them where it could before. Unlike neighbors and characters on either side of a forced +/// line break are not a pair. +#[test] +fn bunri_kinshi_keeps_jlreq_pairs_in_both_composers() { + use KinsokuShoriType::PushOutOnly; + let ends = |text: &str, bunri: bool| candidates(text, &pairs(text, bunri), None, Kinsoku::Hard).iter().map(|b| b.end).collect::<Vec<_>>(); + let text = "あい——う〳〵え"; + assert_eq!(ends(text, false), [1, 2, 3, 4, 5, 6, 7]); + assert_eq!(ends(text, true), [1, 2, 4, 5, 7]); + assert_eq!(ends("———あ", false), [1, 2, 3]); + assert_eq!(ends("———あ", true), [3], "a chain stays together"); + assert_eq!(ends("あ〴〵い", true), [1, 3]); + assert_eq!(ends("—…あ", true), ends("—…あ", false), "unlike neighbors are not a pair"); + for text in ["あ……い", "あ‥‥い"] { + assert!(!ends(text, true).contains(&2), "{text}"); + } + let marked = |text: &str| pairs(text, true).iter().filter(|g| g.keeps_next).count(); + assert_eq!((marked("……‥‥〳〵〴〵"), marked("—\u{2028}—"), marked("—〵"), marked("〵〳")), (4, 0, 0, 0)); + // 35 pt holds "あい—" and not "あい——". + let greedy = + |g: &[SGlyph], width: f64| break_line(text, g, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[]).0; + assert_eq!(greedy(&pairs(text, false), 35.0), 3, "off: the line ends between the dashes"); + assert_eq!(greedy(&pairs(text, true), 35.0), 2, "on: both dashes go to the next line"); + assert_eq!(breaking::word_width(&pairs("——あ", true), 0, &[], Kinsoku::Hard), 20.0); + assert_eq!(breaking::word_width(&pairs("——あ", false), 0, &[], Kinsoku::Hard), 10.0); + let params = Params { justify_last: false, ragged: true, tolerance: f64::INFINITY, uniform_from: 0, hyphen_limit: 0 }; + let on = pairs("あい——う", true); + let cands = candidates("あい——う", &on, None, Kinsoku::Hard); + assert_eq!(compose(&on, &|_| 35.0, &|_| 0.0, &|_| 0.0, &cands, &[], ¶ms), Some(vec![(2, false), (5, false)])); +} + +/// Bunri-Kinshi: a pair wider than the line stays whole on it and the next line starts after it; +/// every Kinsoku Shori Type and both kinsoku sets leave pairs whole at every width, and each line +/// moves on. No Break and a pair next to it stay together as one run. +#[test] +fn bunri_kinshi_progresses_with_an_overlong_pair_and_no_break() { + use KinsokuShoriType::{PushInFirst, PushOutFirst, PushOutOnly}; + let line = |text: &str, g: &[SGlyph], i: usize, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType| { + break_line(text, g, i, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, false, false, &[]).0 + }; + let text = "——あ"; + assert_eq!(line(text, &pairs(text, true), 0, 15.0, Kinsoku::Hard, PushOutOnly), 2, "the pair overflows the line whole"); + assert_eq!(line(text, &pairs(text, true), 2, 15.0, Kinsoku::Hard, PushOutOnly), 3); + assert_eq!(line(text, &pairs(text, false), 0, 15.0, Kinsoku::Hard, PushOutOnly), 1); + let text = "あい——」う〳〵」え"; + let glyphs = pairs(text, true); + for kinsoku in [Kinsoku::Hard, Kinsoku::Soft] { + for ty in [PushInFirst, PushOutFirst, PushOutOnly] { + for width in (10..=90).step_by(5).map(f64::from) { + let mut i = 0; + while i < glyphs.len() { + let end = line(text, &glyphs, i, width, kinsoku, ty); + assert!(end > i, "{kinsoku:?} {ty:?} {width}: no progress at {i}"); + assert!(end != 3 && end != 7, "{kinsoku:?} {ty:?} {width}: a pair split at {end}"); + i = end; + } + } + } + } + let text = "あ——い"; + let mut on = pairs(text, true); + let mut off = pairs(text, false); + for g in on[..2].iter_mut().chain(off[..2].iter_mut()) { + g.no_break = true; + } + assert_eq!(line(text, &on, 0, 25.0, Kinsoku::Hard, PushOutOnly), 3, "No Break and the pair are one run"); + assert_eq!(line(text, &off, 0, 25.0, Kinsoku::Hard, PushOutOnly), 2); + // Push In First closes each gap by up to 1 pt here: 」 needs 3.5 pt, which the four gaps give + // and the three outside the pair do not, so on, い moves to the next line with 」. + let text = "あ〳〵い」"; + assert_eq!(line(text, &pairs(text, false), 0, 46.5, Kinsoku::Hard, PushInFirst), 5); + assert_eq!(line(text, &pairs(text, true), 0, 46.5, Kinsoku::Hard, PushInFirst), 3); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 96b51e7b4c..27e8e39424 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -54,7 +54,7 @@ pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader}; pub use kept::{KeptLayoutsFrame, LayoutStamp, keep_layout, take_kept_layout}; use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2}; pub use layout::{decorations, drawn, fit_first_line, fit_source_lines, layout, layout_with, scaled}; -pub use shape::{line_start_space, spacing_scale}; +pub use shape::{inseparable, line_start_space, spacing_scale}; pub use vectorcraft_doc::TextObject; pub use vectorcraft_doc::{AreaFit, FirstBaseline, TextFlow, VerticalAlign}; diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index c1e2b9e0f5..bf35156d04 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -79,6 +79,9 @@ pub(crate) struct SGlyph { /// A letter of a Korean word kept whole (Korean Line Break Anywhere off): a CJK character /// here doesn't let the line break after it ([`keep_korean_words`]). pub keeps_word: bool, + /// Bunri-Kinshi (Paragraph panel menu): the glyph's cluster ends with the first character of + /// an inseparable pair, so a line may not break after it ([`keep_inseparable_pairs`]). + pub keeps_next: bool, /// No Break (Character panel menu): keep this glyph on the same line as the neighbouring /// glyphs that have it. pub no_break: bool, @@ -349,6 +352,36 @@ pub(crate) fn keep_korean_words(sg: &mut [SGlyph]) { } } +/// Bunri-Kinshi: mark each glyph of `sg` (a paragraph's glyphs, shaped from `text`, which their +/// bytes index) whose cluster ends right before the second character of an inseparable pair +/// ([`inseparable`]) as [`SGlyph::keeps_next`], so a line never breaks inside the pair. A pair is +/// two neighboring source characters, whatever their fonts and styles; an inline graphic or a +/// shaped cluster holding both characters is never a pair. +pub(crate) fn keep_inseparable_pairs(text: &str, sg: &mut [SGlyph]) { + for k in 1..sg.len() { + let (a, b) = (&sg[k - 1], &sg[k]); + if a.byte == b.byte || a.inline.is_some() || b.inline.is_some() || a.byte + a.len != b.byte { + continue; + } + let last = text.get(a.byte..b.byte).and_then(|s| s.chars().next_back()).unwrap_or(a.ch); + if inseparable(last, b.ch) { + sg[k - 1].keeps_next = true; + } + } +} + +/// Is `a` followed by `b` an inseparable pair that Bunri-Kinshi keeps on one line? VectorCraft's +/// set is the pairs W3C JLREQ lists for its class cl-08 (https://www.w3.org/TR/jlreq/#cl-08): two +/// em dashes, two horizontal ellipses, two two-dot leaders, and the upper half of a vertical kana +/// repeat mark (U+3033 or U+3034) followed by its lower half (U+3035). File writers call it to +/// report whether a paragraph's lines depend on Bunri-Kinshi. +pub fn inseparable(a: char, b: char) -> bool { + matches!( + (a, b), + ('\u{2014}', '\u{2014}') | ('\u{2026}', '\u{2026}') | ('\u{2025}', '\u{2025}') | ('\u{3033}', '\u{3035}') | ('\u{3034}', '\u{3035}') + ) +} + /// Is `c` Hangul: a syllable, a jamo or a compatibility jamo (full or half width)? pub(crate) fn is_hangul(c: char) -> bool { c.script() == Script::Hangul @@ -1166,6 +1199,7 @@ fn inline_glyph( inline: Some(InlineBox { art: drawn, xf }), word_end: false, keeps_word: false, + keeps_next: false, no_break: st.no_break, vertical_roman: false, cursive: false, @@ -1638,6 +1672,7 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe inline: None, word_end: false, keeps_word: false, + keeps_next: false, no_break: gst.no_break, vertical_roman: gst.standard_vertical_roman, cursive, diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 30713d33aa..345a1af735 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -2552,6 +2552,87 @@ fn korean_words_stay_whole_unless_line_break_anywhere_is_on() { assert_eq!(ends(&t), ["가나다 라".len(), text.len(), second + first, second + text.len()]); } +/// Bunri-Kinshi (a paragraph attribute): on, two em dashes that do not both fit move to the next +/// line together, with either composer and in vertical type; off, or with kinsoku None, the line +/// breaks between them. Each paragraph follows its own setting. +#[test] +fn bunri_kinshi_keeps_two_em_dashes_on_one_line() { + let text = "あいう——え"; + let (split, kept) = ("あいう—".len(), "あいう".len()); + let measure = |vertical: bool| { + let mut t = point("あいう—", style(20.0)); + t.vertical = vertical; + layout(db(), &t).glyphs.iter().map(|g| g.advance).sum::<f64>() + }; + let ends = |t: &TextObject| layout(db(), t).lines.iter().map(|l| l.end).collect::<Vec<_>>(); + for vertical in [false, true] { + let fits = measure(vertical); + let frame = if vertical { Rect::new(0.0, 0.0, 400.0, fits + 1.0) } else { Rect::new(0.0, 0.0, fits + 1.0, 400.0) }; + for composer in [Composer::SingleLine, Composer::EveryLine] { + let mut t = area(text, style(20.0), frame, Justify::Left); + (t.vertical, t.para.composer) = (vertical, composer); + assert_eq!(ends(&t), [split, text.len()], "{composer:?} vertical {vertical}: off"); + t.para.bunri_kinshi = true; + assert_eq!(ends(&t), [kept, text.len()], "{composer:?} vertical {vertical}: on"); + t.para.kinsoku = vectorcraft_doc::Kinsoku::None; + assert_eq!(ends(&t), [split, text.len()], "{composer:?} vertical {vertical}: kinsoku None"); + } + } + let fits = measure(false); + let mut t = area(&format!("{text}\n{text}"), style(20.0), Rect::new(0.0, 0.0, fits + 1.0, 400.0), Justify::Left); + t.para.composer = Composer::SingleLine; + let on = vectorcraft_doc::ParaStyle { bunri_kinshi: true, ..t.para.clone() }; + t.set_paragraph_styles(vec![on, t.para.clone()]); + let second = text.len() + 1; + assert_eq!(ends(&t), [kept, text.len(), second + split, second + text.len()]); +} + +/// Bunri-Kinshi: a justified line spreads its room over the gaps between its CJK characters but not +/// over the gap inside a vertical kana repeat mark, and still fills its width. +#[test] +fn bunri_kinshi_adds_no_justification_space_inside_a_pair() { + let text = "あ〳〵いう"; + let upper = "あ".len(); + let natural = layout(db(), &point(text, style(20.0))).glyphs.iter().find(|g| g.byte == upper).map(|g| g.advance).unwrap(); + let frame = Rect::new(0.0, 0.0, 300.0, 400.0); + let set = |bunri: bool| { + let mut t = area(text, style(20.0), frame, Justify::JustifyAll); + t.para.bunri_kinshi = bunri; + let l = layout(db(), &t); + assert!((l.lines[0].x1 - frame.x1).abs() < 1e-6, "{bunri}: {:?}", l.lines[0]); + l.glyphs.iter().find(|g| g.byte == upper).map(|g| g.advance).unwrap() + }; + assert!(set(false) > natural + 1.0, "off: the gap after 〳 widens"); + assert!((set(true) - natural).abs() < 1e-6, "on: the pair keeps its spacing"); + // Nor does a Maximum Letter Spacing widen the gap between two em dashes on a line with spaces. + let text = "ab——cd ef"; + let dash = "ab".len(); + let natural = layout(db(), &point(text, style(20.0))).glyphs.iter().find(|g| g.byte == dash).map(|g| g.advance).unwrap(); + let set = |bunri: bool| { + let mut t = area(text, style(20.0), frame, Justify::JustifyAll); + t.para.justification.letter_spacing = [0.0, 0.0, 100.0]; + t.para.bunri_kinshi = bunri; + let l = layout(db(), &t); + l.glyphs.iter().find(|g| g.byte == dash).map(|g| g.advance).unwrap() + }; + assert!(set(false) > natural + 1.0, "off: letter spacing widens the gap after the first dash"); + assert!((set(true) - natural).abs() < 1e-6, "on: the dashes keep their spacing"); + // A Desired Letter Spacing adds no space inside the pair on a ragged line either. + let desired = |bunri: bool| { + let mut t = area(text, style(20.0), frame, Justify::Left); + t.para.justification.letter_spacing = [0.0, 50.0, 100.0]; + t.para.bunri_kinshi = bunri; + let l = layout(db(), &t); + let adv = |b: usize| l.glyphs.iter().find(|g| g.byte == b).map(|g| g.advance).unwrap(); + (adv(dash), adv(0)) + }; + let (off, _) = desired(false); + assert!(off > natural + 1.0, "off: Desired spacing widens the gap after the first dash"); + let (on, a) = desired(true); + assert!((on - natural).abs() < 1e-6, "on: the dashes keep their spacing"); + assert!(a > layout(db(), &point(text, style(20.0))).glyphs[0].advance + 1.0, "on: other letters still take Desired spacing"); +} + /// No Break (Character panel menu) keeps the marked words on one line, where the same text wraps /// at the space without the mark. #[test] diff --git a/crates/ui-egui/src/dialogs/para_style_options.rs b/crates/ui-egui/src/dialogs/para_style_options.rs index 1d59bdd679..c343e0682c 100644 --- a/crates/ui-egui/src/dialogs/para_style_options.rs +++ b/crates/ui-egui/src/dialogs/para_style_options.rs @@ -1,7 +1,7 @@ //! Paragraph Style Options fields shared by editing and creating a paragraph style: General, Basic //! Paragraph Formats (alignment, indents and spacing), Tabs (the style's tab stops), Composition //! (composer and Roman Hanging Punctuation), Hyphenation, Justification and East Asian Composition -//! Settings (Korean Line Break Anywhere). +//! Settings (Bunri-Kinshi and Korean Line Break Anywhere). //! //! Each field is unset (empty, or a checkbox in its unset state) or set, and an unset field leaves //! its attribute out of the style. Distances are shown and typed in the General unit and kept in @@ -105,6 +105,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { "glyph_max", "auto_leading", "single_word", + "bunri_kinshi", "korean_line_break_anywhere", "tabs", "section", @@ -234,7 +235,7 @@ pub(crate) fn fields_from_attrs(attrs: &Map<String, Value>, unit: Unit) -> Map<S fields.insert(key.into(), distance_field(attrs.get(key), unit)); } fields.insert("composer".into(), attrs.get("composer").filter(|v| v.is_string()).cloned().unwrap_or(Value::Null)); - for key in ["roman_hanging", "hyphenate", "korean_line_break_anywhere"] { + for key in ["roman_hanging", "hyphenate", "bunri_kinshi", "korean_line_break_anywhere"] { fields.insert(key.into(), attrs.get(key).filter(|v| v.is_boolean()).cloned().unwrap_or(Value::Null)); } let hyphenation = attrs.get("hyphenation"); @@ -378,8 +379,10 @@ pub(crate) fn edited_attrs(d: &Dialog) -> Result<Map<String, Value>, String> { let value = if same { None } else { tab_stops(d, &original, unit)? }; keep_or(&mut attrs, "tabs", same, value); - let same = unchanged("korean_line_break_anywhere", "__resetEastAsian"); - keep_or(&mut attrs, "korean_line_break_anywhere", same, switch("korean_line_break_anywhere")); + for key in ["bunri_kinshi", "korean_line_break_anywhere"] { + let same = unchanged(key, "__resetEastAsian"); + keep_or(&mut attrs, key, same, switch(key)); + } Ok(attrs) } @@ -559,9 +562,11 @@ fn reset_tabs(d: &mut Dialog) { d.fields.insert("__tabSel".into(), json!(0)); } -/// Reset Panel in East Asian Composition Settings: Korean Line Break Anywhere becomes unset. +/// Reset Panel in East Asian Composition Settings: Bunri-Kinshi and Korean Line Break Anywhere +/// become unset. fn reset_east_asian(d: &mut Dialog) { d.fields.insert("__resetEastAsian".into(), json!(true)); + d.fields.insert("bunri_kinshi".into(), Value::Null); d.fields.insert("korean_line_break_anywhere".into(), Value::Null); } @@ -905,8 +910,12 @@ pub(crate) fn justification_body(ui: &mut egui::Ui, d: &mut Dialog) { }); } -/// East Asian Composition Settings: Korean Composition Settings › Korean Line Break Anywhere. +/// East Asian Composition Settings: Bunri-Kinshi, and Korean Composition Settings › Korean Line +/// Break Anywhere. pub(crate) fn east_asian_body(ui: &mut egui::Ui, d: &mut Dialog) { + egui::Grid::new("paragraph-style-bunri-kinshi").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| { + switch(ui, d, "bunri_kinshi", tl!("Bunri-Kinshi"), true); + }); ui.strong(tl!("Korean Composition Settings")); egui::Grid::new("paragraph-style-east-asian").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| { switch(ui, d, "korean_line_break_anywhere", tl!("Korean Line Break Anywhere"), true); diff --git a/crates/ui-egui/src/dialogs/para_style_options/tests.rs b/crates/ui-egui/src/dialogs/para_style_options/tests.rs index 467b0dc1ae..0890491606 100644 --- a/crates/ui-egui/src/dialogs/para_style_options/tests.rs +++ b/crates/ui-egui/src/dialogs/para_style_options/tests.rs @@ -158,7 +158,7 @@ fn reset_panel_clears_only_its_section() { let mut app = app(); let all = json!({"justify": "Center", "space_after": 6.0, "composer": "singleLine", "roman_hanging": true, "hyphenate": true, "hyphenation": {"words_longer_than": 8}, "justification": {"auto_leading": 150.0}, "tabs": [{"position": 20.0}], - "korean_line_break_anywhere": true}); + "korean_line_break_anywhere": true, "bunri_kinshi": true}); app.run("paraStyle.new", json!({"name": "Body", "attrs": all})).unwrap(); let stored = def(&app, "Body"); for (section, cleared) in [ @@ -167,7 +167,7 @@ fn reset_panel_clears_only_its_section() { ("composition", &["composer", "roman_hanging"]), ("hyphenation", &["hyphenate", "hyphenation"]), ("justification", &["justification"]), - ("eastAsian", &["korean_line_break_anywhere"]), + ("eastAsian", &["korean_line_break_anywhere", "bunri_kinshi"]), ] { app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); assert!(app.run("ui.paraStyleOptions.resetPanel", json!({})).is_err(), "General has no Reset Panel"); @@ -286,6 +286,34 @@ fn korean_line_break_anywhere_is_set_by_the_east_asian_section() { assert!(paras(&app, id)[0].korean_line_break_anywhere, "the style's paragraphs take it"); } +/// East Asian Composition Settings › Bunri-Kinshi sets the style's attribute, which its paragraphs +/// take; set off again, the style holds it off, and unset, the style leaves it out. +#[test] +fn bunri_kinshi_is_set_by_the_east_asian_section() { + let mut app = app(); + let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "あ——い"})).unwrap()["id"].as_u64().unwrap(); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {}})).unwrap(); + app.run("paraStyle.apply", json!({"name": "Body", "id": id})).unwrap(); + assert!(!paras(&app, id)[0].bunri_kinshi); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + assert!(app.ui.dialog.as_ref().unwrap().fields["bunri_kinshi"].is_null()); + set(&mut app, "section", json!("eastAsian")); + set(&mut app, "bunri_kinshi", json!(true)); + super::super::confirm(&mut app).unwrap(); + assert_eq!(def(&app, "Body").get("bunri_kinshi"), Some(&json!(true))); + assert!(paras(&app, id)[0].bunri_kinshi, "the style's paragraphs take it"); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["bunri_kinshi"], json!(true)); + set(&mut app, "bunri_kinshi", json!(false)); + super::super::confirm(&mut app).unwrap(); + assert_eq!(def(&app, "Body").get("bunri_kinshi"), Some(&json!(false))); + assert!(!paras(&app, id)[0].bunri_kinshi); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + set(&mut app, "bunri_kinshi", Value::Null); + super::super::confirm(&mut app).unwrap(); + assert_eq!(def(&app, "Body").get("bunri_kinshi"), None); +} + /// The rows of the `tabs` field of the open dialog. fn tab_rows(app: &VectorcraftApp) -> Vec<Value> { app.ui.dialog.as_ref().unwrap().fields["tabs"].as_array().cloned().unwrap_or_default() @@ -534,6 +562,7 @@ fn every_section_draws_its_fields() { ("hyphenation", "Hyphenation Zone:"), ("justification", "Glyph Scaling:"), ("eastAsian", "Korean Line Break Anywhere"), + ("eastAsian", "Bunri-Kinshi"), ] { set(&mut app, "section", json!(section)); let mut texts = String::new(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 4ca1e345f9..003e92c1b3 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2856,6 +2856,7 @@ digits Malayalam Малаялам Roman Baseline Латинска основна линия Roman Hanging Punctuation Латинска висяща пунктуация Korean Line Break Anywhere Пренасяне на корейски текст навсякъде + Bunri-Kinshi Bunri-Kinshi East Asian Composition Settings Настройки за източноазиатска композиция Korean Composition Settings Настройки за корейска композиция Rotate Завъртане diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 972414922b..145fa764f4 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -2007,6 +2007,7 @@ digits Malayalam Malajalam Right-to-Left Paragraph Direction Smjer pasusa s desna nalijevo Roman Hanging Punctuation Latinična viseća interpunkcija Korean Line Break Anywhere Prelom korejskog reda bilo gdje + Bunri-Kinshi Bunri-Kinshi East Asian Composition Settings Postavke istočnoazijske kompozicije Korean Composition Settings Postavke korejske kompozicije Select a text object to edit its paragraph attributes. Odaberite tekstualni objekat da uredite njegove atribute pasusa. diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index ebfbcd26db..d35885b99e 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3663,6 +3663,7 @@ digits Malayalam Malaiàlam Roman Baseline Línia de base romana Roman Hanging Punctuation Puntuació penjant romana Korean Line Break Anywhere Salt de línia coreà a qualsevol lloc + Bunri-Kinshi Bunri-Kinshi East Asian Composition Settings Configuració de composició de l’Àsia oriental Korean Composition Settings Configuració de composició coreana Rotate Gira diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f5ea30ac2b..905e3bc798 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2038,6 +2038,7 @@ digits Malayalam Malajálamské Right-to-Left Paragraph Direction Směr odstavce zprava doleva Roman Hanging Punctuation Visící latinková interpunkce Korean Line Break Anywhere Zalomení korejského řádku kdekoli + Bunri-Kinshi Bunri-Kinshi East Asian Composition Settings Nastavení východoasijské sazby Korean Composition Settings Nastavení korejské sazby Select a text object to edit its paragraph attributes. Vyberte textový objekt, abyste mohli upravit atributy jeho odstavce. diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index fbc690cf7f..80e949b40d 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1840,6 +1840,7 @@ digits Malayalam Malayalam Right-to-Left Paragraph Direction Afsnitsretning fra højre mod venstre Roman Hanging Punctuation Romersk hængende tegnsætning Korean Line Break Anywhere Koreansk linjeskift hvor som helst + Bunri-Kinshi Bunri-Kinshi East Asian Composition Settings Indstillinger for østasiatisk komposition Korean Composition Settings Indstillinger for koreansk komposition Select a text object to edit its paragraph attributes. Markér et tekstobjekt for at redigere dets afsnitsattributter. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e776bc1264..250ec8a0e9 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2902,6 +2902,7 @@ digits Malayalam Malayalam Roman Baseline Lateinische Grundlinie Roman Hanging Punctuation Hängende Interpunktion (lateinisch) Korean Line Break Anywhere Koreanischer Zeilenumbruch überall + Bunri-Kinshi Bunri-Kinshi East Asian Composition Settings Einstellungen für ostasiatischen Satz Korean Composition Settings Einstellungen für koreanischen Satz Rotate Drehen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 711bdeb099..fdf2e6d26e 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1978,6 +1978,7 @@ font-technology Variable Μεταβλητές Rise… Ανύψωση… Roman Hanging Punctuation Κρεμαστή στίξη όρθιας γραφής Korean Line Break Anywhere Αλλαγή γραμμής κορεατικών οπουδήποτε + Bunri-Kinshi Bunri-Kinshi East Asian Composition Settings Ρυθμίσεις σύνθεσης ανατολικής Ασίας Korean Composition Settings Ρυθμίσεις κορεατικής σύνθεσης Rotate Περιστροφή diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2ab24e227f..d7fa20a656 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2935,6 +2935,7 @@ digits Malayalam Malayálam Roman Baseline Línea de base latina Roman Hanging Punctuation Puntuación colgante romana Korean Line Break Anywhere Salto de línea coreano en cualquier lugar + Bunri-Kinshi Bunri-Kinshi East Asian Composition Settings Ajustes de composición de Asia oriental Korean Composition Settings Ajustes de composición coreana Rotate Rotar diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 650898ae92..56d323d549 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1999,6 +1999,7 @@ digits Malayalam Malajalam Right-to-Left Paragraph Direction Kappaleen suunta oikealta vasemmalle Roman Hanging Punctuation Latinalaiset riippuvat välimerkit Korean Line Break Anywhere Korean rivinvaihto missä tahansa + Bunri-Kinshi Bunri-Kinshi East Asian Composition Settings Itäaasialaisen ladonnan asetukset Korean Composition Settings Korean ladonnan asetukset Select a text object to edit its paragraph attributes. Valitse tekstiobjekti, jonka kappalemääritteitä haluat muokata. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 95e9c9e4c2..51ef29d7b1 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2927,6 +2927,7 @@ digits Malayalam Malayalam Roman Baseline Ligne de base romaine Roman Hanging Punctuation Ponctuation romaine hors justification Korean Line Break Anywhere Saut de ligne coréen n’importe où + Bunri-Kinshi Bunri-Kinshi East Asian Composition Settings Paramètres de composition d’Asie orientale Korean Composition Settings Paramètres de composition coréenne Rotate Rotation diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 3d9850d4f5..ae236c981d 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -4000,6 +4000,7 @@ antiAlias Sharp शार्प Roman Baseline रोमन बेसलाइन Roman Hanging Punctuation रोमन हैंगिंग विराम चिह्न Korean Line Break Anywhere कोरियाई लाइन ब्रेक कहीं भी + Bunri-Kinshi बुनरी-किंशी East Asian Composition Settings पूर्वी एशियाई संरचना सेटिंग्स Korean Composition Settings कोरियाई संरचना सेटिंग्स Rotate घुमाएँ diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 948931ac13..471a6d073e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3510,6 +3510,7 @@ mark-style Japanese Japán Roman Baseline Latin alapvonal Roman Hanging Punctuation Latin függő írásjelek Korean Line Break Anywhere Koreai sortörés bárhol + Bunri-Kinshi Bunri-Kinshi East Asian Composition Settings Kelet-ázsiai szedési beállítások Korean Composition Settings Koreai szedési beállítások Rotate Elforgatás diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 55e2e73bf1..dcee600fc0 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1840,6 +1840,7 @@ digits Malayalam Malayalam Right-to-Left Paragraph Direction Arah paragraf kanan ke kiri Roman Hanging Punctuation Tanda baca gantung Romawi Korean Line Break Anywhere Putus baris Korea di mana saja + Bunri-Kinshi Bunri-Kinshi East Asian Composition Settings Pengaturan komposisi Asia Timur Korean Composition Settings Pengaturan komposisi Korea Select a text object to edit its paragraph attributes. Pilih objek teks untuk mengedit atribut paragrafnya. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 81dffc495f..c5ee46c80c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2978,6 +2978,7 @@ digits Malayalam Malayalam Roman Baseline Linea di base romana Roman Hanging Punctuation Punteggiatura sporgente romana Korean Line Break Anywhere Interruzione di riga coreana ovunque + Bunri-Kinshi Bunri-Kinshi East Asian Composition Settings Impostazioni di composizione dell’Asia orientale Korean Composition Settings Impostazioni di composizione coreana Rotate Ruota diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9d8ec83078..2258ffa985 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2966,6 +2966,7 @@ mark-style Japanese 日本式 Right: 右: Roman Hanging Punctuation 欧文ぶら下がり Korean Line Break Anywhere 韓国語の任意位置での改行 + Bunri-Kinshi 分離禁止 East Asian Composition Settings 東アジア言語の組版設定 Korean Composition Settings 韓国語の組版設定 Rotate 回転 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 2630b64269..10dc746e15 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1769,6 +1769,7 @@ digits Malayalam მალაიალამური Right-to-Left Paragraph Direction აბზაცის მიმართულება მარჯვნიდან მარცხნივ Roman Hanging Punctuation ლათინური გამოწეული პუნქტუაცია Korean Line Break Anywhere კორეული სტრიქონის გადატანა ნებისმიერ ადგილას + Bunri-Kinshi Bunri-Kinshi East Asian Composition Settings აღმოსავლეთ აზიური აწყობის პარამეტრები Korean Composition Settings კორეული აწყობის პარამეტრები Select a text object to edit its paragraph attributes. აბზაცის ატრიბუტების რედაქტირებისთვის მონიშნეთ ტექსტური ობიექტი. diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 30ac7dfb76..91b4dd37e7 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2955,6 +2955,7 @@ mark-style Japanese 일본식 Right: 오른쪽: Roman Hanging Punctuation 로마자 걸림 문장 부호 Korean Line Break Anywhere 한글 어디서나 줄 바꿈 + Bunri-Kinshi 분리 금지 East Asian Composition Settings 동아시아 조판 설정 Korean Composition Settings 한글 조판 설정 Rotate 회전 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 57ca80dc20..234fad3d6d 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1854,6 +1854,7 @@ digits Malayalam Malayalam Right-to-Left Paragraph Direction Alinearichting van rechts naar links Roman Hanging Punctuation Romeinse hangende leestekens Korean Line Break Anywhere Koreaans regeleinde overal + Bunri-Kinshi Bunri-Kinshi East Asian Composition Settings Instellingen voor Oost-Aziatische zetting Korean Composition Settings Instellingen voor Koreaanse zetting Select a text object to edit its paragraph attributes. Selecteer een tekstobject om de alineakenmerken ervan te bewerken. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 4094d29879..56592675af 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1956,6 +1956,7 @@ digits Malayalam Malajalam Right-to-Left Paragraph Direction Kierunek akapitu od prawej do lewej Roman Hanging Punctuation Wysunięta interpunkcja łacińska Korean Line Break Anywhere Koreański podział wiersza w dowolnym miejscu + Bunri-Kinshi Bunri-Kinshi East Asian Composition Settings Ustawienia składu wschodnioazjatyckiego Korean Composition Settings Ustawienia składu koreańskiego Select a text object to edit its paragraph attributes. Zaznacz obiekt tekstowy, aby edytować atrybuty akapitu. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a367880be2..83d04f4861 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2465,6 +2465,7 @@ digits Malayalam Malaiala Roman Baseline Linha de Base Romana Roman Hanging Punctuation Pontuação Pendurada Romana Korean Line Break Anywhere Quebra de Linha Coreana em Qualquer Lugar + Bunri-Kinshi Bunri-Kinshi East Asian Composition Settings Configurações de Composição do Leste Asiático Korean Composition Settings Configurações de Composição Coreana Rotate Girar diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e03cc555b1..f424160f84 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2965,6 +2965,7 @@ digits Malayalam Малаялам Roman Baseline Римская базовая линия Roman Hanging Punctuation Римская висячая пунктуация Korean Line Break Anywhere Перенос корейского текста в любом месте + Bunri-Kinshi Bunri-Kinshi East Asian Composition Settings Параметры восточноазиатского набора Korean Composition Settings Параметры корейского набора Rotate Повернуть diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 83a29c8531..e68d6238b1 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2871,6 +2871,7 @@ digits Malayalam Malayalam Roman Baseline Baslinje (roman) Roman Hanging Punctuation Hängande interpunktion (roman) Korean Line Break Anywhere Koreansk radbrytning var som helst + Bunri-Kinshi Bunri-Kinshi East Asian Composition Settings Inställningar för östasiatisk sättning Korean Composition Settings Inställningar för koreansk sättning Rotate Rotera diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c781b80ebb..d193678b83 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2738,6 +2738,7 @@ digits Malayalam Malayalam Roman Baseline Roma Taban Çizgisi Roman Hanging Punctuation Roma Asılı Noktalama İşaretleri Korean Line Break Anywhere Korece Satırı Her Yerde Kes + Bunri-Kinshi Bunri-Kinshi East Asian Composition Settings Doğu Asya Dizgi Ayarları Korean Composition Settings Korece Dizgi Ayarları Rotate Döndür diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 75e062627c..a9bb61490a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2970,6 +2970,7 @@ digits Malayalam Малаялам Roman Baseline Римська базова лінія Roman Hanging Punctuation Римська висяча пунктуація Korean Line Break Anywhere Перенесення корейського тексту будь-де + Bunri-Kinshi Bunri-Kinshi East Asian Composition Settings Параметри східноазійського набору Korean Composition Settings Параметри корейського набору Rotate Повернути diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 7df0386f9e..f123489546 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3274,6 +3274,7 @@ mark-style Japanese Kiểu Nhật Roman Baseline Đường cơ sở Roman Roman Hanging Punctuation Dấu câu treo Roman Korean Line Break Anywhere Ngắt dòng tiếng Hàn ở bất kỳ đâu + Bunri-Kinshi Bunri-Kinshi East Asian Composition Settings Cài đặt dàn trang Đông Á Korean Composition Settings Cài đặt dàn trang tiếng Hàn Rotate Xoay diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9b593188b9..9e74c0f4fc 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2834,6 +2834,7 @@ digits Malayalam 马拉雅拉姆数字 Roman Baseline 罗马基线 Roman Hanging Punctuation 西文悬挂标点 Korean Line Break Anywhere 韩文任意位置换行 + Bunri-Kinshi 分离禁止 East Asian Composition Settings 东亚排版设置 Korean Composition Settings 韩文排版设置 Rotate 旋转 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index d2245176dd..c0137a6f68 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3946,6 +3946,7 @@ digits Malayalam 馬來亞拉姆數字 Roman Baseline 羅馬基線 Roman Hanging Punctuation 羅馬懸掛標點 Korean Line Break Anywhere 韓文任意位置換行 + Bunri-Kinshi 分離禁止 East Asian Composition Settings 東亞排版設定 Korean Composition Settings 韓文排版設定 Rotate 旋轉 diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index cabca387ae..7ad184ea99 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -877,6 +877,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } }); }); + // Inseparable pairs stay on one line; only with a kinsoku set. + let (kinsoku_on, bunri) = bunri_kinshi_item(app); + if menu_item(ui, tl!("Bunri-Kinshi"), has && kinsoku_on, bunri) { + toggle_bunri_kinshi(app); + } // Korean lines break between any two syllables, not only between words. let anywhere = shared_para(app, |p| p.korean_line_break_anywhere) == Some(true); if menu_item(ui, tl!("Korean Line Break Anywhere"), has, anywhere) { @@ -904,6 +909,19 @@ fn set_kinsoku_type(app: &mut VectorcraftApp, key: &str) { format(app, json!({"kinsokuType": key})); } +/// Bunri-Kinshi's menu item for the paragraphs the panel acts on ([`format`]): (available, checked). +/// It is available when none of them has kinsoku None, and checked when every one of them has it. +fn bunri_kinshi_item(app: &VectorcraftApp) -> (bool, bool) { + (shared_para(app, |p| p.kinsoku == Kinsoku::None) == Some(false), shared_para(app, |p| p.bunri_kinshi) == Some(true)) +} + +/// Bunri-Kinshi (Paragraph panel menu): a click turns it off for all the paragraphs the panel acts +/// on ([`format`]) when every one of them has it, and on for all of them otherwise, in one undo step. +fn toggle_bunri_kinshi(app: &mut VectorcraftApp) { + let on = shared_para(app, |p| p.bunri_kinshi) == Some(true); + format(app, json!({"bunriKinshi": !on})); +} + /// Korean Line Break Anywhere (Paragraph panel menu): the menu item is checked when every paragraph /// the panel acts on ([`format`]) has it, and a click turns it off for all of them then, and on for /// all of them otherwise, in one undo step. @@ -915,8 +933,8 @@ fn toggle_korean_line_break_anywhere(app: &mut VectorcraftApp) { /// Reset Panel: the paragraphs the panel acts on ([`format`]) take Auto alignment, no list, no /// indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification /// options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi -/// Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Roman -/// Hanging Punctuation and Korean Line Break Anywhere off, no kashidas and no tab stops, as one undo +/// Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Bunri-Kinshi, +/// Roman Hanging Punctuation and Korean Line Break Anywhere off, no kashidas and no tab stops, as one undo /// step named Reset Panel (inside an undo group that is already open, as part of its step). fn reset_panel(app: &mut VectorcraftApp) { let h = HyphenationOptions::default(); @@ -937,7 +955,8 @@ fn reset_panel(app: &mut VectorcraftApp) { "wordSpacing": j.word_spacing, "letterSpacing": j.letter_spacing, "glyphScaling": j.glyph_scaling, "autoLeading": j.auto_leading, "singleWordJustification": "fullJustify", "direction": "auto", "leadingModel": "romanBaseline", "mojikumi": "lineEndHalf", "burasagari": "standard", "kinsoku": "hard", - "kinsokuType": "pushOutOnly", "composer": "everyLine", "romanHangingPunctuation": false, "koreanLineBreakAnywhere": false + "kinsokuType": "pushOutOnly", "composer": "everyLine", "romanHangingPunctuation": false, "koreanLineBreakAnywhere": false, + "bunriKinshi": false }), ); para_cmd(app, "text.tabs.clear", json!({})); diff --git a/crates/ui-egui/src/panels/paragraph/tests.rs b/crates/ui-egui/src/panels/paragraph/tests.rs index 517ef1eeab..177ecd08fb 100644 --- a/crates/ui-egui/src/panels/paragraph/tests.rs +++ b/crates/ui-egui/src/panels/paragraph/tests.rs @@ -166,6 +166,48 @@ fn korean_line_break_anywhere_toggles_the_selected_paragraphs_in_one_undo_step() assert_eq!(flags(&app), (false, false), "Reset Panel turns it off"); } +/// Bunri-Kinshi: new type has it off; the menu item turns it on for the paragraphs the Type tool's +/// selection touches in one undo step, is unchecked while the selected paragraphs differ, and is +/// unavailable while any of them has kinsoku None, which keeps the setting. Reset Panel turns it off. +#[test] +fn bunri_kinshi_toggle_follows_kinsoku_and_the_selected_paragraphs() { + let (mut app, id) = editing_text("あ——い\nう……え\n"); + app.session.prefs.show_east_asian_options = true; + let flags = |app: &VectorcraftApp| { + let node = app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("text") }; + (text.para_at(0).bunri_kinshi, text.para_at(1).bunri_kinshi) + }; + assert_eq!(flags(&app), (false, false), "new type has it off"); + select_range(&mut app, 0, 3); + assert_eq!(bunri_kinshi_item(&app), (true, false)); + let history = app.session.active().unwrap().history.undo.len(); + toggle_bunri_kinshi(&mut app); + assert_eq!(flags(&app), (true, false)); + assert_eq!(app.session.active().unwrap().history.undo.len(), history + 1); + assert_eq!(bunri_kinshi_item(&app), (true, true)); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(bunri_kinshi_item(&app), (true, false), "mixed: unchecked"); + toggle_bunri_kinshi(&mut app); + assert_eq!(flags(&app), (true, true)); + // Kinsoku None on one paragraph: the item is unavailable and the setting stays. + let second = "あ——い\n".len(); + app.run("text.setFormat", json!({"id": id, "start": second, "end": second, "kinsoku": "none"})).unwrap(); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(bunri_kinshi_item(&app), (false, true)); + assert_eq!(flags(&app), (true, true)); + app.run("text.setFormat", json!({"id": id, "kinsoku": "soft"})).unwrap(); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(bunri_kinshi_item(&app), (true, true)); + toggle_bunri_kinshi(&mut app); + assert_eq!(flags(&app), (false, false)); + app.session.set_tool_option("selectAll", &json!(true)); + toggle_bunri_kinshi(&mut app); + app.session.set_tool_option("selectAll", &json!(true)); + reset_panel(&mut app); + assert_eq!(flags(&app), (false, false), "Reset Panel turns it off"); +} + #[test] fn paragraph_mixed_indent_fields_show_blank() { let (mut app, id) = editing_text("A\nB\n"); diff --git a/docs/mcp.md b/docs/mcp.md index 62a4212472..4b5018e3f2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7536,6 +7536,27 @@ Panel sets it back to Push Out Only. One undo step. {"name":"run_command","arguments":{"command":"text.setFormat","params":{"kinsokuType":"pushInFirst"}}} ``` +## Bunri-Kinshi + +`text.setFormat {bunriKinshi: bool}` (Paragraph panel menu › Bunri-Kinshi, shown with the East Asian options) turns it +on or off for the paragraphs of the selected type (or `ids`, or the paragraphs a `start` / `end` range touches). On, a +line never breaks inside an inseparable pair: two em dashes (U+2014), two horizontal ellipses (U+2026), two two-dot +leaders (U+2025), or the upper half of a vertical kana repeat mark (U+3033 or U+3034) followed by its lower half +(U+3035), the pairs W3C JLREQ lists for its class cl-08 (https://www.w3.org/TR/jlreq/#cl-08), which are VectorCraft's +set. A chain of em dashes stays together as well. A pair is two neighboring characters, whatever their fonts and +styles; a forced line break between them ends the line as before. Both composers, vertical type, Kinsoku Shori Type, +hyphenation and No Break follow it, and a pair wider than the line stays whole on it. Justification adds no space inside +a pair. The setting works only with a Kinsoku Set: with Kinsoku Set None the paragraph keeps the value without effect, +and the menu item is unavailable. Off by default, for new type and for paragraphs saved before the option. It is saved +with the paragraph (`bunri_kinshi` in `para`) only when on, paragraph styles carry it (Paragraph Style Options › East +Asian Composition Settings), and Reset Panel turns it off. A value that is not true or false is refused. One undo +step. `.ai` editing data does not hold it, and a save reports a paragraph with it on and a pair in it as left out. The help page: +https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html + +```json +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"bunriKinshi":true}}} +``` + ## Korean Line Break Anywhere `text.setFormat {koreanLineBreakAnywhere: bool}` (Paragraph panel menu › Korean Line Break Anywhere, shown with the diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5ec97e34c1..e046028548 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1218,8 +1218,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests/hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines. Optical Margin Alignment and Roman Hanging Punctuation hang right-to-left paragraphs of point and area type, and type on a path whose first paragraph runs right to left, too. Auto aligns a right-to-left line at its right, or at the end bracket of a path, so it hangs there. A right-to-left line is read from right to left: where it hangs at its left margin, `."` ending it hangs both glyphs. The Paragraph panel menu's Kinsoku Shori Type (`text.setFormat {kinsokuType}`, with the East Asian options) picks Push In First, Push Out First or Push Out Only for each paragraph: a character the Kinsoku Set keeps off a line's start or end is set on the line, with the line's gaps next to CJK characters closed by at most a tenth of an em each to fit, or moved to the next line with the character before it. Push Out First pushes in only when pushing out would leave the line more than an em short. Push Out Only is the default, the setting is saved only when it is not, and paragraph styles carry it. + The Paragraph panel menu's Bunri-Kinshi (`text.setFormat {bunriKinshi}`, with the East Asian options, and the East Asian Composition Settings section of Paragraph Style Options) keeps inseparable pairs on one line in each paragraph that has it: two em dashes, two ellipses, two two-dot leaders and the two halves of a vertical kana repeat mark (the pairs of W3C JLREQ's class cl-08, VectorCraft's set). Both composers and vertical type follow it, and justification adds no space inside a pair. It works with a Kinsoku Set only, is off by default and is saved only when on; editing the set of pairs is not available. The Paragraph panel menu's Korean Line Break Anywhere (`text.setFormat {koreanLineBreakAnywhere}`, with the East Asian options, and the East Asian Composition Settings section of Paragraph Style Options) sets for each paragraph whether a line may break between any two Hangul syllables. When it is off, a word that contains Hangul breaks only at its spaces and after punctuation, or where the word is wider than the line, and takes no hyphen. New type has it off; paragraphs saved before the option read as on, so their lines do not move. - The Paragraph panel menu's Reset Panel (`text.setStyle`, `text.setFormat` and `text.tabs.clear`) gives the selected type's paragraphs, or the paragraphs the Type tool's selection touches, Auto alignment, no indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Roman Hanging Punctuation and Korean Line Break Anywhere off, no kashidas and no tab stops, as one undo step. + The Paragraph panel menu's Reset Panel (`text.setStyle`, `text.setFormat` and `text.tabs.clear`) gives the selected type's paragraphs, or the paragraphs the Type tool's selection touches, Auto alignment, no indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Bunri-Kinshi, Roman Hanging Punctuation and Korean Line Break Anywhere off, no kashidas and no tab stops, as one undo step. The Paragraph panel menu's Hyphenation… (`ui.hyphenation`, `text.setFormat {wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone, hyphenateCapitalized}`) sets each paragraph's hyphenation limits: the shortest word that breaks, the fewest letters on each side of the hyphen, the most hyphenated lines in a row, a zone at the right edge where the Single-line Composer breaks before a word instead of hyphenating it, and whether capitalized words break. The defaults are words longer than 5 letters, 3 letters on each side, no limit or zone, and capitalized words hyphenated; paragraph styles and the native file keep the options. PDF export with type as text writes the hyphen that ends a line broken inside a word as a hyphen character where the line draws it, and the file opens with the hyphen in the line's text; kashidas are drawn as paths. The Paragraph panel's Insert Kashidas (with the Indic options; `text.setFormat {kashida}`) lengthens each Arabic word of a justified horizontal area type line with a kashida, a tatweel between its last pair of joined letters, before the word spaces widen: Short, Medium and Long kashidas are at most a quarter, half and a whole em long, Stylistic ones are whole tatweels up to an em, and None (the default) leaves the line as before; paragraph styles and the native file keep the setting. From 344d756bf610acecc8984a2bf664fd0326c4e32b Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 09:10:51 -0800 Subject: [PATCH 69/92] Color management: proof view commands redraw only when the view changes view.proofSetup, view.proofColors, view.overprintPreview and view.separationsPreview stored the proof view and raised every open document's revision on each run. They did this also when the request asked for the view that was already showing, for example Proof Colors on while it is on, or Proof Setup with no params. Each such run redrew every canvas. When the active document's revision changes, the canvas also sends a tool that asks for it a new hover event, although the pointer has not moved. - The four commands compare the new proof view with the current one. When the two are equal, the commands leave the view and the revisions as they are. Separations Preview's visible plates are compared as a set, so the same plates in another order do not redraw. - view.separationsPreview with `plates` naming a plate twice shows it once, so one `toggle` of that plate hides it. - color.loadProfile with a new file of the CMYK profile that Proof Setup targets gives the documents new revisions, as it does for a working space. Before, running Proof Setup again redrew the documents through the new file; it now leaves an unchanged view alone. - color.removeProfile gives the documents new revisions only when Proof Setup targets the removed profile. --- crates/engine/src/cmd/colormgmt.rs | 52 ++++++++++++--- crates/engine/src/command_sets.rs | 2 +- crates/engine/src/tests/build.rs | 12 +++- crates/engine/src/tests/colormgmt.rs | 96 +++++++++++++++++++++++++++ crates/engine/tests/color_settings.rs | 54 +++++++++++++++ crates/ui-egui/src/control/tests.rs | 4 +- 6 files changed, 206 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 9ec21b6541..1046c4a948 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -210,8 +210,12 @@ fn load_profile(s: &mut Session, p: &Value) -> Result<Value> { let remembered = s.store_profile(&info.name, &bytes); // A new file of a working space: the active settings still hold the old one. let working = cms::active_settings(); - if working.rgb == info.name || working.cmyk == info.name { + let is_working = working.rgb == info.name || working.cmyk == info.name; + if is_working { cms::set_active(&working).map_err(|e| bad(C, e.to_string()))?; + } + // A new file of a working space, or of the CMYK profile View → Proof Setup targets: redraw. + if is_working || proof_targets(&info.name) { touch_all(s); } Ok(json!({"name": info.name, "kind": info.kind, "remembered": remembered})) @@ -233,10 +237,17 @@ fn remove_profile(s: &mut Session, p: &Value) -> Result<Value> { return Err(bad(C, format!("no profile named `{name}`"))); } s.unstore_profile(&info.name); - touch_all(s); + if proof_targets(&info.name) { + touch_all(s); + } Ok(json!({"removed": info.name})) } +/// Whether View → Proof Setup targets the CMYK profile `name`. +fn proof_targets(name: &str) -> bool { + matches!(proof::view().setup.target, ProofTarget::Cmyk(t) if t == name) +} + #[cfg(target_arch = "wasm32")] fn load_profile(_: &mut Session, _: &Value) -> Result<Value> { Err(bad("color.loadProfile", "loading profiles from disk isn't available in the browser")) @@ -763,17 +774,33 @@ fn proof_setup(s: &mut Session, p: &Value) -> Result<Value> { if let Some(on) = on_param(p, "proof", C)? { v.proof_colors = on; } - proof::set_view(v.clone()); - touch_all(s); + store_view(s, v.clone()); Ok(view_json(&v)) } +/// Make `v` the proof view and redraw every document. When `v` equals the current view, the view +/// and the revisions stay as they are; Separations Preview's visible plates are compared as a set. +fn store_view(s: &mut Session, v: proof::ProofView) { + let shown = |v: &proof::ProofView| proof::ProofView { + separations: v.separations.as_ref().map(|vis| { + let mut vis = vis.clone(); + vis.sort(); + vis.dedup(); + vis + }), + ..v.clone() + }; + if shown(&v) != shown(&proof::view()) { + proof::set_view(v); + touch_all(s); + } +} + fn toggle_view(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut proof::ProofView, Option<bool>)) -> Result<Value> { let on = on_param(p, "on", cmd)?; let mut v = proof::view(); f(&mut v, on); - proof::set_view(v.clone()); - touch_all(s); + store_view(s, v.clone()); Ok(view_json(&v)) } @@ -805,7 +832,15 @@ fn separations(s: &mut Session, p: &Value) -> Result<Value> { match p.get("plates") { None | Some(Value::Null) => {} Some(Value::Array(a)) if a.iter().all(Value::is_string) => { - v.separations = Some(a.iter().filter_map(Value::as_str).map(check).collect::<Result<Vec<_>>>()?); + let mut vis: Vec<String> = Vec::new(); + for n in a.iter().filter_map(Value::as_str) { + let n = check(n)?; + // A plate named twice is shown once. + if !vis.contains(&n) { + vis.push(n); + } + } + v.separations = Some(vis); } Some(_) => { let key = "plates"; @@ -824,8 +859,7 @@ fn separations(s: &mut Session, p: &Value) -> Result<Value> { vis.push(n); } } - proof::set_view(v); - touch_all(s); + store_view(s, v); Ok(plates_json(s)) } diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index 95b44776f0..c9de286514 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -84,7 +84,7 @@ 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, 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). +/// fonts can give the documents new revisions (they draw again, and type lays out again). const WAIT_FOR_TOOL_EDIT: &[&str] = &[ "script.run", "command.batch", diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 0fc2bcb476..49a806b258 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -793,8 +793,9 @@ fn shape_builder_applies_a_merge_before_what_comes_next() { 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. + // The proof views apply the merge first by command id (`WAIT_FOR_TOOL_EDIT`), also when + // the view does not change and no document gets 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(); }), @@ -807,6 +808,13 @@ fn shape_builder_applies_a_merge_before_what_comes_next() { ("Proof Setup", |s, _, _| { s.execute("view.proofSetup", &json!({})).unwrap(); }), + ("a proof view that changes", |s, _, _| { + let _v = crate::tests_colormgmt::DefaultView::new(); + s.execute("view.proofColors", &json!({"on": true})).unwrap(); + let on = s.doc().unwrap().revision; + s.execute("view.proofColors", &json!({"on": false})).unwrap(); + assert!(s.doc().unwrap().revision > on, "the view changes and the documents get new revisions"); + }), ("a preference", |s, _, _| { let every = s.prefs.gridline_every; s.execute("prefs.set", &json!({"key": "gridlineEvery", "value": every + 10.0})).unwrap(); diff --git a/crates/engine/src/tests/colormgmt.rs b/crates/engine/src/tests/colormgmt.rs index ae8ac0c4f0..493f569805 100644 --- a/crates/engine/src/tests/colormgmt.rs +++ b/crates/engine/src/tests/colormgmt.rs @@ -455,6 +455,7 @@ fn overprint_black_multiplies_in_preview() { #[test] fn view_toggles_redraw_without_dirtying() { let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let _v = DefaultView::new(); let mut s = session(); let rev = s.doc().unwrap().revision; let r = s.execute("view.proofColors", &json!({"on": true})).unwrap(); @@ -475,6 +476,101 @@ fn view_toggles_redraw_without_dirtying() { s.execute("view.overprintPreview", &json!({"on": false})).unwrap(); } +/// Sets the default proof view when made and again when dropped, also after a failed assertion, +/// so the next test starts from the default view. +pub(crate) struct DefaultView; + +impl DefaultView { + pub(crate) fn new() -> Self { + vectorcraft_render::proof::set_view(Default::default()); + DefaultView + } +} + +impl Drop for DefaultView { + fn drop(&mut self) { + vectorcraft_render::proof::set_view(Default::default()); + } +} + +/// Runs `cmd` with `p` twice from the default proof view: the first run changes the view and +/// redraws, and the second asks for the view that is already showing and leaves the revision alone. +fn repeat_keeps_revision(cmd: &str, p: Value) { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let _v = DefaultView::new(); + let mut s = session(); + let rev = s.doc().unwrap().revision; + s.execute(cmd, &p).unwrap(); + let changed = s.doc().unwrap().revision; + assert!(changed > rev, "{cmd} {p}: the first run changes the view"); + s.execute(cmd, &p).unwrap(); + assert_eq!(s.doc().unwrap().revision, changed, "{cmd} {p}: the same view again"); +} + +/// Runs `cmd` with `p` once from the default proof view, which `p` leaves as it is. +fn default_view_keeps_revision(cmd: &str, p: Value) { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let _v = DefaultView::new(); + let mut s = session(); + let rev = s.doc().unwrap().revision; + s.execute(cmd, &p).unwrap(); + assert_eq!(s.doc().unwrap().revision, rev, "{cmd} {p}"); + assert_eq!(vectorcraft_render::proof::view(), Default::default(), "{cmd} {p}"); +} + +#[test] +fn proof_setup_with_nothing_changed_keeps_the_revision() { + repeat_keeps_revision("view.proofSetup", json!({"target": "deuteranopia"})); + repeat_keeps_revision("view.proofSetup", json!({"simulatePaper": true, "proof": true})); + default_view_keeps_revision("view.proofSetup", json!({})); + default_view_keeps_revision("view.proofSetup", json!({"target": "workingCmyk", "proof": false})); +} + +#[test] +fn proof_colors_with_nothing_changed_keeps_the_revision() { + repeat_keeps_revision("view.proofColors", json!({"on": true})); + default_view_keeps_revision("view.proofColors", json!({"on": false})); +} + +#[test] +fn overprint_preview_with_nothing_changed_keeps_the_revision() { + repeat_keeps_revision("view.overprintPreview", json!({"on": true})); + default_view_keeps_revision("view.overprintPreview", json!({"on": false})); +} + +#[test] +fn separations_preview_with_nothing_changed_keeps_the_revision() { + repeat_keeps_revision("view.separationsPreview", json!({"on": true})); + repeat_keeps_revision("view.separationsPreview", json!({"only": "Cyan"})); + repeat_keeps_revision("view.separationsPreview", json!({"plates": ["Magenta", "Black"]})); + default_view_keeps_revision("view.separationsPreview", json!({"on": false})); +} + +/// Separations Preview's visible plates are a set: the same plates in another order, or with one +/// named twice, leave the revision alone, and a plate named twice hides with one `toggle`. +#[test] +fn separations_preview_compares_the_visible_plates_as_a_set() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let _v = DefaultView::new(); + let mut s = session(); + let sep = |s: &mut Session, p: Value| { + s.execute("view.separationsPreview", &p).unwrap(); + s.doc().unwrap().revision + }; + let shown = sep(&mut s, json!({"plates": ["Magenta", "Black"]})); + assert_eq!(sep(&mut s, json!({"plates": ["Black", "Magenta"]})), shown, "another order"); + assert_eq!(sep(&mut s, json!({"plates": ["Magenta", "Black", "Black"]})), shown, "a plate named twice"); + // Cyan hidden and shown again goes to the end of the list; every plate is shown. + sep(&mut s, json!({"on": false})); + sep(&mut s, json!({"on": true})); + sep(&mut s, json!({"toggle": "Cyan"})); + let all = sep(&mut s, json!({"toggle": "Cyan"})); + assert_eq!(sep(&mut s, json!({"plates": ["Cyan", "Magenta", "Yellow", "Black"]})), all, "every plate in plate order"); + sep(&mut s, json!({"plates": ["Cyan", "Cyan"]})); + let r = s.execute("view.separationsPreview", &json!({"toggle": "Cyan"})).unwrap(); + assert!(r["plates"].as_array().unwrap().iter().all(|p| p["visible"] == false), "{r}"); +} + #[test] fn proof_setup_simulates_tritanopia() { let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/crates/engine/tests/color_settings.rs b/crates/engine/tests/color_settings.rs index c84921f6de..c3d6df771b 100644 --- a/crates/engine/tests/color_settings.rs +++ b/crates/engine/tests/color_settings.rs @@ -446,6 +446,60 @@ fn loading_the_working_rgb_profile_again_takes_effect_at_once() { assert!(redrawn, "the canvas redraws"); } +/// Loading a new file of the CMYK profile View → Proof Setup targets, or removing that profile, +/// gives the documents new revisions, as a working space's new file does; loading or removing +/// another profile does not. +#[test] +fn loading_or_removing_the_proof_profile_redraws() { + let _g = settings(); + // A built-in CMYK profile's file renamed `name`: padded with spaces, which names drop. + let renamed = |builtin: &str, name: &str| { + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>(); + let mut bytes = cms::icc_bytes(builtin).unwrap().to_vec(); + let (from, to) = (utf16(builtin), utf16(&format!("{name:<width$}", width = builtin.len()))); + let at = bytes.windows(from.len()).position(|w| w == from).unwrap(); + bytes[at..at + to.len()].copy_from_slice(&to); + bytes + }; + let dir = fresh_dir("reload-proof-cmyk"); + std::fs::create_dir_all(&dir).unwrap(); + let _reset = ColorSettingsReset { profile: None, paths: vec![dir.clone()] }; + let load = |s: &mut Session, name: &str| { + let path = dir.join(format!("{}.icc", name.len())); + std::fs::write(&path, renamed(cms::GENERIC_CMYK, name)).unwrap(); + let r = s.execute("color.loadProfile", &json!({"path": path.to_str().unwrap()})).unwrap(); + assert_eq!(r["name"], name); + s.doc().unwrap().revision + }; + let (proofed, other) = ("Reload Proof CMYK", "Reload Other CMYK 2"); + let _view = ProofReset([proofed, other]); + let mut s = session(); + load(&mut s, proofed); + load(&mut s, other); + exec(&mut s, "view.proofSetup", json!({"target": format!("cmyk:{proofed}"), "proof": true})); + let revision = s.doc().unwrap().revision; + assert_eq!(load(&mut s, other), revision, "another profile's file"); + let reloaded = load(&mut s, proofed); + assert!(reloaded > revision, "the proof profile's file"); + exec(&mut s, "color.removeProfile", json!({"name": other})); + assert_eq!(s.doc().unwrap().revision, reloaded, "another profile removed"); + exec(&mut s, "color.removeProfile", json!({"name": proofed})); + assert!(s.doc().unwrap().revision > reloaded, "the proof profile removed"); +} + +/// Sets the default proof view and unregisters the profiles when dropped, also after a failed +/// assertion. +struct ProofReset([&'static str; 2]); + +impl Drop for ProofReset { + fn drop(&mut self) { + vectorcraft_render::proof::set_view(Default::default()); + for name in self.0 { + cms::unregister_icc(name); + } + } +} + /// A folder of its own for the test `tag`, empty. fn fresh_dir(tag: &str) -> std::path::PathBuf { let dir = vectorcraft_testkit::temp_dir(&format!("color-settings-{tag}")); diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 1763fd679a..ac524e0b65 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -679,8 +679,8 @@ fn menu_dialogs_open_after_a_merge_on_another_thread() { } /// 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. +/// computed on another thread apply the merge first, and the document keeps it. The checkboxes +/// leave the view off, so they apply it by command id without giving the documents new revisions. #[test] fn panels_that_redraw_apply_a_merge_on_another_thread_first() { let cases = [ From 41f6400993324e635f745dbf29088a8c5db71b3e Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 09:03:10 -0800 Subject: [PATCH 70/92] Keyboard Shortcuts: rows with the same name get names of their own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several rows of Edit › Keyboard Shortcuts had the same group and label, so a user could not tell them apart: the four Window › Other Library… commands, File › New… (file.new and file.newDialog), Type › Fit Headline (text.fitHeadline and type.fitHeadline), Ui › Reset Panel (the character and paragraph style options) and File › Continue in Background (export and open). Rows that would share an English name now take a name of their own: - a command that an item of the menu bar runs is listed under that item's menu path, for example Window › Swatch Libraries › Other Library…; - a command whose id extends another command's id is listed under that command's label, for example Character Style Options › Reset Panel; - the rows that still share a name show their id after it, except the one row an item of the menu bar runs, so File › New… stays the New Document dialog's row and file.new is File › New… (file.new). Two filter plug-ins with the same menu path and label show their ids too. shortcuts.list gives the new group of the first two kinds of rows in `group`. The dialog's search finds a row by the id it shows. A message about a shortcut that is already in use names every row that has it by its whole name, with its group, instead of its label alone. --- crates/ui-egui/src/shortcut_editor.rs | 301 +++++++++++++++++++++++--- crates/ui-egui/src/shortcuts/tests.rs | 9 +- docs/mcp.md | 33 ++- docs/target-app-parity.md | 1 + 4 files changed, 309 insertions(+), 35 deletions(-) diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index e7c64463de..4e02180017 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -358,6 +358,9 @@ pub struct Entry { /// Menu path ("Object › Arrange") or "Tools". pub group: String, pub is_tool: bool, + /// Keyboard Shortcuts adds the key to the entry's name: another entry has the same group and + /// label ([`distinct_names`]). + pub shows_key: bool, } /// The editor's entries, the Effect menu's effect items and Object › Plug-ins' filter items while @@ -419,20 +422,34 @@ fn build_snapshot(revision: u64) -> Arc<Snapshot> { let effects: Arc<[EffectItem]> = build_effect_items().into(); let filters: Arc<[FilterItem]> = build_filter_items().into(); let (head, tail) = fixed_entries(); - let entries: Arc<[Entry]> = head + let mut entries: Vec<Entry> = head .iter() .cloned() - .chain(effects.iter().map(|e| Entry { key: format!("effect:{}", e.effect), label: e.label.clone(), group: e.group.clone(), is_tool: false })) - .chain(filters.iter().map(|f| Entry { key: format!("plugin:{}", f.id), label: f.label.clone(), group: f.group.clone(), is_tool: false })) + .chain(effects.iter().map(|e| Entry { + key: format!("effect:{}", e.effect), + label: e.label.clone(), + group: e.group.clone(), + is_tool: false, + shows_key: false, + })) + .chain(filters.iter().map(|f| Entry { + key: format!("plugin:{}", f.id), + label: f.label.clone(), + group: f.group.clone(), + is_tool: false, + shows_key: false, + })) .chain(vectorcraft_engine::cmd::plugin::command_specs().into_iter().map(|c| Entry { key: c.id.into(), label: c.label.into(), group: c.menu.join(" › "), is_tool: false, + shows_key: false, })) .chain(tail.iter().cloned()) .collect(); - Arc::new(Snapshot { revision, effects, filters, entries }) + distinct_names(&mut entries); + Arc::new(Snapshot { revision, effects, filters, entries: entries.into() }) } /// Every tool, command and panel the editor can give a shortcut; [`listed`] says which it lists. @@ -446,7 +463,7 @@ fn fixed_entries() -> &'static (Vec<Entry>, Vec<Entry>) { static E: OnceLock<(Vec<Entry>, Vec<Entry>)> = OnceLock::new(); E.get_or_init(|| { let mut v: Vec<Entry> = vectorcraft_tools::catalog::all_tools() - .map(|t| Entry { key: format!("tool:{}", t.id), label: t.label.to_string(), group: "Tools".into(), is_tool: true }) + .map(|t| Entry { key: format!("tool:{}", t.id), label: t.label.to_string(), group: "Tools".into(), is_tool: true, shows_key: false }) .collect(); let mut seen = HashSet::new(); // The commands listed only because an item of the menu bar runs them as a key press would @@ -461,13 +478,14 @@ fn fixed_entries() -> &'static (Vec<Entry>, Vec<Entry>) { by_the_menu_bar.insert(c.id); } let group = if c.menu.is_empty() { id_group(c.id) } else { c.menu.join(" › ") }; - v.push(Entry { key: c.id.into(), label: c.label.into(), group, is_tool: false }); + v.push(Entry { key: c.id.into(), label: c.label.into(), group, is_tool: false, shows_key: false }); } v.extend(crate::state::all_panels().map(|(id, label)| Entry { key: format!("panel:{id}"), label: label.to_string(), group: "Window".into(), is_tool: false, + shows_key: false, })); // Each effect of the Effect menu comes next, by its own key (#3296): see [`snapshot`]. let mut tail = vec![]; @@ -479,14 +497,16 @@ fn fixed_entries() -> &'static (Vec<Entry>, Vec<Entry>) { if !own { by_the_menu_bar.insert(id); } - tail.push(Entry { key: id.to_string(), label: label.to_string(), group: id_group(id), is_tool: false }); + tail.push(Entry { key: id.to_string(), label: label.to_string(), group: id_group(id), is_tool: false, shows_key: false }); } // A command listed only because an item of the menu bar runs it can have the group and // label of another entry. When that entry is an engine command that no item of the menu bar // runs, the command the item runs keeps the name and the other entry is shown in the group // of its id: File › Export Selection… runs `file.exportSelection`, as the menu item does, // and `document.exportSelection` shows as Document › Export Selection…. Otherwise the - // command is left out: Window › Learn stays the Learn panel's entry. + // command is left out: Window › Learn stays the Learn panel's entry. Entries that still + // have the same name, plug-ins included, are given names of their own by + // [`distinct_names`]. let name = |e: &Entry| (e.group.clone(), e.label.clone()); let new_names: HashSet<_> = v.iter().chain(&tail).filter(|e| by_the_menu_bar.contains(e.key.as_str())).map(name).collect(); for e in v.iter_mut().chain(tail.iter_mut()) { @@ -621,9 +641,21 @@ pub(crate) fn shown_group(group: &str) -> String { group.split(" › ").map(|s| tl!(s)).collect::<Vec<_>>().join(" › ") } -/// How Keyboard Shortcuts names entry `e`: a tool by its label, the others after their menu. +/// How Keyboard Shortcuts names entry `e`: a tool by its label, the others after their menu, and +/// an entry that [`Entry::shows_key`] with its key after the name. pub(crate) fn shown_name(e: &Entry) -> String { - if e.is_tool || e.group.is_empty() { shown_label(e).to_string() } else { format!("{} › {}", shown_group(&e.group), shown_label(e)) } + name_of(e, &shown_group(&e.group), shown_label(e)) +} + +/// [`shown_name`] in English, for a status or error message, which is translated when shown. +fn english_name(e: &Entry) -> String { + name_of(e, &e.group, &e.label) +} + +/// Entry `e`'s name with its group and label as `group` and `label`. +fn name_of(e: &Entry, group: &str, label: &str) -> String { + let name = if e.is_tool || e.group.is_empty() { label.to_string() } else { format!("{group} › {label}") }; + if e.shows_key { format!("{name} ({})", e.key) } else { name } } /// The group of a command no menu holds: the first part of its id, capitalised (`edit.x` → "Edit"). @@ -632,6 +664,107 @@ fn id_group(id: &str) -> String { c.next().map(|f| f.to_uppercase().collect::<String>() + c.as_str()).unwrap_or_default() } +/// Gives entries that have the same group and label names of their own, and leaves the others as +/// they are. It runs on the whole snapshot, plug-ins included, after [`fixed_entries`] has shown an +/// engine command in the group of its id or left out a command listed only because an item of the +/// menu bar runs it: +/// 1. A command that an item of the menu bar runs, as a key press would run it, is shown under +/// that item's menu path ([`menu_bar_path`]): the four Other Library… commands go under Window › +/// Swatch Libraries, Brush Libraries, Graphic Style Libraries and Symbol Libraries. +/// 2. A command whose id is another command's id with one more part is shown under that command's +/// label: Reset Panel goes under Character Style Options and Paragraph Style Options. +/// 3. Entries that still have the same name show their key after it ([`Entry::shows_key`]), except +/// the one entry an item of the menu bar runs: File › New… stays the New Document dialog's +/// entry, and `file.new` shows as File › New… (file.new). An entry whose name is then the name +/// another entry has with its key (a plug-in named "Blur (plugin:a)") shows its key too. +fn distinct_names(entries: &mut [Entry]) { + // The entries of each name that more than one entry has. + fn shared(entries: &[Entry]) -> Vec<Vec<usize>> { + let mut by_name: HashMap<String, Vec<usize>> = HashMap::new(); + for (i, e) in entries.iter().enumerate() { + by_name.entry(english_name(e)).or_default().push(i); + } + by_name.into_values().filter(|list| list.len() > 1).collect() + } + for i in shared(entries).into_iter().flatten() { + let Some(e) = entries.get_mut(i).filter(|e| !e.is_tool) else { continue }; + if let Some(path) = menu_bar_path(&e.key) { + e.group = path.to_string(); + } else if let Some((parent, _)) = e.key.rsplit_once('.').and_then(|(parent, _)| command_doc(parent)) { + e.group = parent.trim_end_matches('…').to_string(); + } + } + // A pass gives every entry of a shared name but one its key, so a name is shared again only by + // an entry whose label ends with another entry's key; one pass per entry is enough. + for _ in 0..entries.len() { + let lists = shared(entries); + if lists.is_empty() { + break; + } + for list in lists { + let plain: Vec<usize> = list.iter().copied().filter(|&i| entries.get(i).is_some_and(|e| !e.shows_key)).collect(); + let on_the_menu_bar: Vec<usize> = + plain.iter().copied().filter(|&i| entries.get(i).is_some_and(|e| !e.is_tool && menu_bar_path(&e.key).is_some())).collect(); + // The entry an item of the menu bar runs keeps the plain name, unless another entry + // already shows that name with its key. + let keeps = on_the_menu_bar.first().copied().filter(|_| on_the_menu_bar.len() == 1 && plain.len() == list.len()); + for i in plain { + if let Some(e) = entries.get_mut(i) + && keeps != Some(i) + { + e.shows_key = true; + } + } + } + } +} + +/// The (label, params doc) of engine or UI command `id`. +fn command_doc(id: &str) -> Option<(&'static str, &'static str)> { + vectorcraft_engine::find_command(id).map(|c| (c.label, c.params)).or_else(|| menus::UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| (c.1, c.3))) +} + +/// The (menu path, params) of each item of the menu bar that [`menu_bar_items`] reads, by command +/// id: "Window › Swatch Libraries" for Window › Swatch Libraries › Other Library…. +fn menu_bar_paths() -> &'static HashMap<&'static str, Vec<(String, Value)>> { + fn walk(items: &[menus::Item], path: &mut Vec<String>, out: &mut HashMap<&'static str, Vec<(String, Value)>>) { + for it in items { + match it { + menus::Item::Cmd(label, id, p) if on_the_bar(id, label, p) => out.entry(*id).or_default().push((path.join(" › "), p.clone())), + menus::Item::Sub(name, children) => { + path.push(name.to_string()); + walk(children, path, out); + path.pop(); + } + _ => {} + } + } + } + static PATHS: OnceLock<HashMap<&'static str, Vec<(String, Value)>>> = OnceLock::new(); + PATHS.get_or_init(|| { + let mut out = HashMap::new(); + for (name, items) in menus::menu_tree() { + walk(&items, &mut vec![name.to_string()], &mut out); + } + out + }) +} + +/// The menu path of the item of the menu bar that runs command `id` as a key press runs it +/// ([`a_menu_bar_item_runs`]): the first item that runs it with no params, or else the first item, +/// whose params all its items give. +fn menu_bar_path(id: &str) -> Option<&'static str> { + let (_, params) = command_doc(id)?; + let items = menu_bar_paths().get(id).filter(|_| a_menu_bar_item_runs(id, params))?; + chosen_path(items) +} + +/// The menu path [`menu_bar_path`] takes from a command's (menu path, params) items: the first +/// item with no params, or else the first item. +fn chosen_path(items: &[(String, Value)]) -> Option<&str> { + items.iter().find(|(_, p)| is_bare(p)).or(items.first()).map(|(path, _)| path.as_str()) +} + /// Does a command's params doc (`{a?, b?} …`) say it runs with no params, as a shortcut runs it? fn runs_without_params(params: &str) -> bool { params @@ -688,9 +821,7 @@ fn bar_items(tree: &[(&'static str, Vec<menus::Item>)]) -> HashMap<&'static str, fn walk(items: &[menus::Item], out: &mut HashMap<&'static str, Vec<(&'static str, Value)>>) { for it in items { match it { - menus::Item::Cmd(label, id, p) if !menus::lists_plugin(id, p) && !menus::shows_a_name(id, label) => { - out.entry(*id).or_default().push((*label, p.clone())) - } + menus::Item::Cmd(label, id, p) if on_the_bar(id, label, p) => out.entry(*id).or_default().push((*label, p.clone())), menus::Item::Sub(_, children) => walk(children, out), _ => {} } @@ -703,6 +834,12 @@ fn bar_items(tree: &[(&'static str, Vec<menus::Item>)]) -> HashMap<&'static str, out } +/// Is the menu item with `label` that runs command `id` with params `p` one [`bar_items`] reads: it +/// lists no installed plug-in and shows no name that is user data? +fn on_the_bar(id: &str, label: &str, p: &Value) -> bool { + !menus::lists_plugin(id, p) && !menus::shows_a_name(id, label) +} + fn is_bare(p: &Value) -> bool { p.as_object().is_none_or(|o| o.is_empty()) } @@ -932,7 +1069,7 @@ pub fn assign_in(km: &mut Keymap, key: &str, chord: Option<&str>, force: bool) - .iter() .map(|h| { let k = entry_key(&h.command); - entry(&k).map_or(k, |e| e.label.clone()) + entry(&k).map_or(k, |e| english_name(&e)) }) .collect(); Err(format!("{} is already used by {}", menus::pretty_shortcut(&vc_format(&chord)), names.join(", "))) @@ -1364,12 +1501,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { Cow::Borrowed(_) => view.texts.get(i).cloned().flatten(), Cow::Owned(km) => dialog_shortcuts_with(km, &e.key, view.backtick.as_deref()), }; - let matches = e.label.to_lowercase().contains(&q) - || shown_label(e).to_lowercase().contains(&q) - || e.group.to_lowercase().contains(&q) - || shown_group(&e.group).to_lowercase().contains(&q) - || sc.as_deref().is_some_and(|s| s.to_lowercase().contains(&q)); - if !q.is_empty() && !matches { + if !q.is_empty() && !row_matches(e, &q, sc.as_deref()) { continue; } let row = ui.horizontal(|ui| { @@ -1547,6 +1679,18 @@ fn drop_hidden(d: &mut Dialog, overrides: &Overrides) { } } +/// Does the search text `q` (lowercase) find entry `e`, whose shortcut text is `sc`? It does when +/// `e`'s label or group, in English or as shown, the key it shows ([`Entry::shows_key`]) or its +/// shortcut contains `q`. +fn row_matches(e: &Entry, q: &str, sc: Option<&str>) -> bool { + e.label.to_lowercase().contains(q) + || shown_label(e).to_lowercase().contains(q) + || e.group.to_lowercase().contains(q) + || shown_group(&e.group).to_lowercase().contains(q) + || (e.shows_key && e.key.to_lowercase().contains(q)) + || sc.is_some_and(|s| s.to_lowercase().contains(q)) +} + /// Apply a recorded chord to `key` in the working set, warning about (and resolving) conflicts. fn record(d: &mut Dialog, km: &mut Keymap, key: &str, chord: &str) { d.fields.insert("__recording".into(), json!("")); @@ -1554,7 +1698,7 @@ fn record(d: &mut Dialog, km: &mut Keymap, key: &str, chord: &str) { Ok(removed) => { d.fields.insert("set".into(), json!(CUSTOM)); if let Some(first) = removed.first() { - let names: Vec<String> = removed.iter().map(|k| entry(k).map(|e| e.label.clone()).unwrap_or(k.clone())).collect(); + let names: Vec<String> = removed.iter().map(|k| entry(k).map(|e| shown_name(&e)).unwrap_or(k.clone())).collect(); d.fields.insert( "__message".into(), json!(crate::i18n::fmt( @@ -1888,7 +2032,7 @@ mod tests { /// An Export Text line: the row's name as the dialog shows it, a tab and `shortcut`. fn text_row(key: &str, shortcut: &str) -> String { let e = entry(key).unwrap_or_else(|| panic!("no entry {key}")); - if e.is_tool || e.group.is_empty() { format!("{}\t{shortcut}", e.label) } else { format!("{} › {}\t{shortcut}", e.group, e.label) } + format!("{}\t{shortcut}", english_name(&e)) } /// Export Text writes the set's name, then the Tools and the Menu Commands rows in the dialog's @@ -1901,11 +2045,7 @@ mod tests { let text = export_text("VectorCraft Defaults", &ov); let km = keymap_with(&ov); let names = |tools: bool| -> Vec<String> { - entries() - .iter() - .filter(|e| e.is_tool == tools && listed(km.overrides(), e)) - .map(|e| if e.is_tool || e.group.is_empty() { format!("{}\t", e.label) } else { format!("{} › {}\t", e.group, e.label) }) - .collect() + entries().iter().filter(|e| e.is_tool == tools && listed(km.overrides(), e)).map(|e| format!("{}\t", english_name(e))).collect() }; let rows = (names(true), names(false)); (text, km, rows.0, rows.1) @@ -2126,7 +2266,7 @@ mod tests { return; } for (alias, edit) in [("F2", "edit.cut"), ("F3", "edit.copy"), ("F4", "edit.paste")] { - let label = entry(edit).map(|e| e.label.clone()).unwrap(); + let label = entry(edit).map(|e| english_name(&e)).unwrap(); assert_eq!(conflicts_with(&Overrides::new(), alias, "tool:pen"), vec![edit.to_string()], "{alias}"); let mut ov = Overrides::new(); let err = assign(&mut ov, "tool:pen", Some(alias), false).unwrap_err(); @@ -2484,6 +2624,111 @@ mod tests { } } + /// No two entries of Keyboard Shortcuts have the same English name. A command that an item of + /// the menu bar runs takes that item's menu path (Window › Swatch Libraries › Other Library…), a + /// command whose id extends another command's id takes that command's label (Character Style + /// Options › Reset Panel), and the other entries that would share a name show their key, except + /// the one an item of the menu bar runs: File › New… is the New Document dialog's entry, and + /// `file.new` is File › New… (file.new). An entry whose name no other entry has keeps its group. + /// The search finds an entry by the key it shows, and a conflict, in `shortcuts.set` and in the + /// dialog, names the entry that has the shortcut by its whole name. + #[test] + fn no_two_entries_have_the_same_name() { + let all = entries(); + let mut by_name: HashMap<String, Vec<&str>> = HashMap::new(); + for e in all.iter() { + by_name.entry(english_name(e)).or_default().push(e.key.as_str()); + } + let shared: Vec<_> = by_name.into_iter().filter(|(_, keys)| keys.len() > 1).collect(); + assert!(shared.is_empty(), "{shared:?}"); + let shown = |id: &str| entry(id).map(|e| (e.group, e.label, e.shows_key)); + for (id, group) in [ + ("window.brushLibrary.other", "Window › Brush Libraries"), + ("window.graphicStyleLibrary.other", "Window › Graphic Style Libraries"), + ("window.swatchLibrary.other", "Window › Swatch Libraries"), + ("window.symbolLibrary.other", "Window › Symbol Libraries"), + ] { + assert_eq!(shown(id), Some((group.into(), "Other Library…".into(), false)), "{id}"); + } + assert_eq!(shown("ui.charStyleOptions.resetPanel"), Some(("Character Style Options".into(), "Reset Panel".into(), false))); + assert_eq!(shown("ui.paraStyleOptions.resetPanel"), Some(("Paragraph Style Options".into(), "Reset Panel".into(), false))); + assert_eq!(shown("file.newDialog"), Some(("File".into(), "New…".into(), false))); + assert_eq!(shown("file.new"), Some(("File".into(), "New…".into(), true))); + assert_eq!(shown("text.fitHeadline"), Some(("Type".into(), "Fit Headline".into(), false))); + assert_eq!(shown("type.fitHeadline"), Some(("Type".into(), "Fit Headline".into(), true))); + for id in ["file.exportInBackground", "file.openInBackground"] { + assert_eq!(shown(id), Some(("File".into(), "Continue in Background".into(), true)), "{id}"); + } + assert_eq!(shown("file.cancelExport"), Some(("File".into(), "Cancel Export".into(), false))); + let e = entry("type.fitHeadline").unwrap(); + assert!(shown_name(&e).ends_with("Fit Headline (type.fitHeadline)"), "{}", shown_name(&e)); + assert!(row_matches(&e, "type.fitheadline", None)); + let e = entry("text.fitHeadline").unwrap(); + assert!(shown_name(&e).ends_with("Fit Headline"), "{}", shown_name(&e)); + assert!(!row_matches(&e, "text.fitheadline", None)); + let mut ov = Overrides::new(); + assign(&mut ov, "window.swatchLibrary.other", Some("Cmd+Alt+Shift+F11"), false).unwrap(); + let err = assign(&mut ov, "window.brushLibrary.other", Some("Cmd+Alt+Shift+F11"), false).unwrap_err(); + assert!(err.ends_with("is already used by Window › Swatch Libraries › Other Library…"), "{err}"); + assign(&mut ov, "type.fitHeadline", Some("Cmd+Alt+Shift+F10"), false).unwrap(); + let err = assign(&mut ov, "text.fitHeadline", Some("Cmd+Alt+Shift+F10"), false).unwrap_err(); + assert!(err.ends_with("is already used by Type › Fit Headline (type.fitHeadline)"), "{err}"); + let mut d = Dialog::new("shortcuts", json!({})); + let mut km = keymap_with(&Overrides::new()); + record(&mut d, &mut km, "type.fitHeadline", "Cmd+Alt+Shift+F10"); + record(&mut d, &mut km, "text.fitHeadline", "Cmd+Alt+Shift+F10"); + assert!(d.str("__message").contains("was already used by Type › Fit Headline (type.fitHeadline) "), "{}", d.str("__message")); + } + + /// Two filter plug-ins with the same name in the same menu show their ids, and a third whose + /// name is one of those names with the id stays apart from it. A filter with that name in + /// another menu, or with a label that differs by its "…", shows no id. + #[test] + fn a_plugin_named_like_another_row_with_its_key_stays_apart() { + let row = |key: &str, label: &str, group: &str| Entry { + key: key.into(), + label: label.into(), + group: group.into(), + is_tool: false, + shows_key: false, + }; + let menu = "Object › Plug-ins"; + let mut rows = vec![ + row("plugin:a", "Foo", menu), + row("plugin:b", "Foo", menu), + row("plugin:c", "Foo (plugin:a)", menu), + row("plugin:d", "Bar", menu), + row("plugin:e", "Foo", "Object › Plug-ins › Other"), + row("plugin:f", "Foo…", menu), + ]; + distinct_names(&mut rows); + let names: Vec<String> = rows.iter().map(english_name).collect(); + assert_eq!( + names, + [ + "Object › Plug-ins › Foo (plugin:a)", + "Object › Plug-ins › Foo (plugin:b)", + "Object › Plug-ins › Foo (plugin:a) (plugin:c)", + "Object › Plug-ins › Bar", + "Object › Plug-ins › Other › Foo", + "Object › Plug-ins › Foo…" + ] + ); + } + + /// A command with items in more than one menu is shown under the path of its first item with + /// no params, or else of its first item: Recolor Artwork… under Edit › Edit Colors, not Edit › + /// Edit Colors › Recolor with Preset. + #[test] + fn a_command_on_several_items_takes_the_path_of_its_item_with_no_params() { + let item = |path: &str, p: Value| (path.to_string(), p); + let items = [item("A › B", json!({"x": 1})), item("A › C", Value::Null), item("A › D", json!({}))]; + assert_eq!(chosen_path(&items), Some("A › C")); + assert_eq!(chosen_path(&[item("A › B", json!({"x": 1})), item("A › C", json!({"x": 2}))]), Some("A › B")); + assert_eq!(chosen_path(&[]), None); + assert_eq!(menu_bar_path("ui.recolorDialog"), Some("Edit › Edit Colors")); + } + /// `shortcuts.set`, `shortcuts.list` and `shortcuts.preset` reject a supplied param of the wrong /// type with an error naming it, and change nothing; an omitted or null param keeps its default. #[test] diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index e002d07adf..c1be55ceb0 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -2982,8 +2982,13 @@ fn a_heavy_filter_plugin_runs_from_its_shortcut_as_from_its_menu_item() { fn a_filter_plugin_row_translates_its_path_and_not_its_name() { let zh = crate::i18n::Lang::from_code("zh-hant").unwrap(); crate::i18n::set_current(zh); - let entry = - |key: &str| crate::shortcut_editor::Entry { key: key.into(), label: "Group".into(), group: "Object › Plug-ins".into(), is_tool: false }; + let entry = |key: &str| crate::shortcut_editor::Entry { + key: key.into(), + label: "Group".into(), + group: "Object › Plug-ins".into(), + is_tool: false, + shows_key: false, + }; let path = format!("{} › {}", crate::i18n::tr(zh, "Object"), crate::i18n::tr(zh, "Plug-ins")); let (filter, command) = (crate::shortcut_editor::shown_name(&entry("plugin:a.b")), crate::shortcut_editor::shown_name(&entry("object.group"))); crate::i18n::set_current(crate::i18n::Lang::EN); diff --git a/docs/mcp.md b/docs/mcp.md index 62a4212472..9932b7b450 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4916,12 +4916,35 @@ is listed as Document › Export Selection…. A shortcut already given to `docu When the other entry is anything else, the command is not listed: `window.learn` is left out, and Window › Learn is the Learn panel's entry. +No two rows of Edit › Keyboard Shortcuts have the same English name. Rows that would have the same group and label +are named this way: + +- A command that an item of the menu bar runs, with no params or with the params all its items give, is listed under + that item's menu path: `window.brushLibrary.other`, `window.graphicStyleLibrary.other`, + `window.swatchLibrary.other` and `window.symbolLibrary.other` are listed as Window › Brush Libraries › Other + Library…, Window › Graphic Style Libraries › Other Library…, Window › Swatch Libraries › Other Library… and + Window › Symbol Libraries › Other Library…. +- A command whose id is another command's id with one more part is listed under that command's label: + `ui.charStyleOptions.resetPanel` and `ui.paraStyleOptions.resetPanel` are listed as Character Style Options › + Reset Panel and Paragraph Style Options › Reset Panel. +- The other rows that still have the same name show their id in parentheses after it. The one row of them that an + item of the menu bar runs keeps the name without its id: File › New… is `file.newDialog` and `file.new` is File › + New… (file.new), and Type › Fit Headline is `text.fitHeadline` and `type.fitHeadline` is Type › Fit Headline + (type.fitHeadline). `file.exportInBackground` and `file.openInBackground` are File › Continue in Background + (file.exportInBackground) and File › Continue in Background (file.openInBackground). + +`shortcuts.list` gives such a row's group in `group` and the command's own label in `label`, so the rows that show +their id have the same `group` and `label` there, and `id` tells them apart. `shortcuts.exportText` writes these +names. When a shortcut is already in use, the message names each row that has it by its whole name, group included, +for every row: a `shortcuts.set` error ends with "is already used by Window › Swatch Libraries › Other Library…". + Each installed filter plug-in's Object › Plug-ins item is its own row, with id `plugin:<id>` (the plug-in's id), its -menu label and no default shortcut, so two filters with the same name have two rows. `shortcuts.set {id: -"plugin:<id>", shortcut}`, `shortcuts.list` and Import Shortcut Set use that id, `shortcuts.exportText` writes one line -for the row under its menu path and label, and the key runs what a click on the item runs, `plugin.dialog {id}`. A -removed plug-in's row goes away and its key runs nothing; its shortcut stays saved and applies again when a plug-in -with the same id is installed. +menu label and no default shortcut, so two filters with the same name have two rows. When the two rows have the same +menu path and label, each shows its id after the name. `shortcuts.set {id: "plugin:<id>", shortcut}`, +`shortcuts.list` and Import Shortcut Set use that id, `shortcuts.exportText` writes one line for the row under its menu +path and label, with its id when the row shows it, and the key runs what a click on the item runs, `plugin.dialog +{id}`. A removed plug-in's row goes away and its key runs nothing; its shortcut stays saved and applies again when a +plug-in with the same id is installed. `shortcuts.exportText {path?}` saves the current set's shortcut list as a UTF-8 text file, as Edit › Keyboard Shortcuts › Export Text… does with the dialog's set as edited so far. The file starts with a "Keyboard Shortcuts" line diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5ec97e34c1..2098359d85 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -929,6 +929,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one. - On Linux and the BSDs, Preferences › User Interface › VectorCraft Title Bar on Every Desktop (off by default) uses the app bar as the title bar from the next start in the sessions where the desktop would draw the window's frame (X11, Wayland compositors with server-side decorations such as KDE Plasma, a window opened through XWayland). System Title Bar and `VECTORCRAFT_TITLEBAR` take precedence over it, and `VECTORCRAFT_TITLEBAR` also takes precedence over System Title Bar for the run it is set in. Only the values `app`, `system` and `desktop` take precedence (case-sensitive, surrounding spaces ignored); any other value, such as `unknown`, is ignored and does not override System Title Bar. - Edit › Keyboard Shortcuts lists items of the menu bar and panel menus, as the reference app's [Customize keyboard shortcuts](https://web.archive.org/web/20221205131209id_/https://helpx.adobe.com/illustrator/using/customizing-keyboard-shortcuts.html) page describes for its Menu Commands. A menu command with no default shortcut that needs a value a key press does not give, such as File › Document Setup › Units (`document.setUnits {units}`), is listed only while the user has changed its shortcut. A command stays listed when a menu bar item runs it without a value or when it opens a dialog run without one, as the Variables panel's commands do. + - No two rows of Edit › Keyboard Shortcuts have the same English name (VectorCraft's own rule). A command that an item of the menu bar runs is listed under that item's menu path (Window › Swatch Libraries › Other Library…), a command whose id extends another command's id is listed under that command's label (Character Style Options › Reset Panel), and the rows that still share a name show their id after it, except the one row an item of the menu bar runs (File › New… and File › New… (file.new)). The search finds a row by the id it shows, and a message about a shortcut already in use names the other row with its group. Test `shortcut_editor::tests::no_two_entries_have_the_same_name`. - A shortcut on the key left of 1 runs whatever character that key types, and Edit › Keyboard Shortcuts, the menus in the window, the command palette and tool tips show it as that character once the key has been pressed: `"` on a Turkish Q layout, `~` on a US layout as before. Shortcuts saved as `~` keep working (#4559). - On Windows a maximized window stays inside the work area with the taskbar on any edge (#3242, #3723). - On Windows a maximized window stops one pixel short of each monitor edge where an auto-hidden taskbar sits. From 7b97708c432e8ef2c6b06fb8c0572aacceee22bf Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 10:04:02 -0800 Subject: [PATCH 71/92] Blend: stroke profiles turn with a key path started at another anchor When a blend starts a key's path elsewhere (an open path clicked at its last anchor, a closed path turned to another start or reversed to match the other key's winding), the steps now read that key's width profile, pen pressure, tilt, bearing and rotation along the turned path, and a reversed open path's arrowheads swap ends. A width profile's sides swap when its path is reversed, a closed path's samples move past the new start keeping the step at the old one, and a split width span is sampled so its curve keeps its shape. A key whose subpaths turned differently keeps its stroke as it is. Width profiles in the steps also keep their discontinuous points instead of only the width before them, and are sampled between their points so each key's curve keeps its shape where the other key adds points; keys whose points sit at the same positions are not sampled, so a preset on both keys stays that preset. A sample of one key that lies within a millionth of the other key's step no longer hides that step. --- crates/doc/src/blend.rs | 313 ++++++++++++++++++++++++++++++++++++-- docs/mcp.md | 3 +- docs/target-app-parity.md | 2 +- 3 files changed, 304 insertions(+), 14 deletions(-) diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index d7b41079f1..150f0b5a8e 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -231,7 +231,8 @@ fn lcm(a: usize, b: usize) -> usize { a.checked_div(x).map_or(0, |q| q * b) } -/// Interpolate width profiles (none is uniform) at every position either has a width point. +/// Interpolate width profiles (none is uniform) at every position either has a width point, +/// keeping their discontinuous points (a position where either steps gives two points). fn lerp_profile(x: Option<&WidthProfile>, y: Option<&WidthProfile>, t: f64) -> Option<WidthProfile> { if x.is_none() && y.is_none() { return None; @@ -240,22 +241,45 @@ fn lerp_profile(x: Option<&WidthProfile>, y: Option<&WidthProfile>, t: f64) -> O let (x, y) = (x.unwrap_or(&uniform), y.unwrap_or(&uniform)); let mut at: Vec<f64> = x.points.iter().chain(&y.points).map(|p| p.0).filter(|p| p.is_finite()).collect(); at.sort_by(f64::total_cmp); - at.dedup_by(|a, b| (*a - *b).abs() < 1e-6); - let points = at - .into_iter() - .map(|p| { - let ((l1, r1), (l2, r2)) = (x.at(p), y.at(p)); - (p, lerp_f64(l1, l2, t), lerp_f64(r1, r2, t)) - }) - .collect(); + // Within the distance `around` reads a discontinuous point at, so a point near one keeps it. + at.dedup_by(|a, b| (*a - *b).abs() <= 1e-7); + // Each profile is a smooth curve through its own points: samples between the positions keep + // both curves' shapes where the other profile's points and steps change the tangents. Two + // profiles with points at the same positions need none (so a preset on both keys stays it). + let same_positions = x.points.len() == y.points.len() && x.points.iter().zip(&y.points).all(|(p, q)| (p.0 - q.0).abs() <= 1e-7); + let per_span = if same_positions { 1 } else { (4096 / at.len().max(1)).clamp(1, 8) }; + let mut points = Vec::with_capacity(at.len() * per_span + 2); + let mix = |p: f64, (l1, r1): (f64, f64), (l2, r2): (f64, f64)| (p, lerp_f64(l1, l2, t), lerp_f64(r1, r2, t)); + for (k, &p) in at.iter().enumerate() { + let ((xb, xa), (yb, ya)) = (x.around(p), y.around(p)); + points.push(mix(p, xb, yb)); + // A discontinuous point ends the curve on each side even where both widths match. + if steps_at(x, p) || steps_at(y, p) { + points.push(mix(p, xa, ya)); + } + if let Some(&q) = at.get(k + 1) { + for j in 1..per_span { + let f = p + (q - p) * j as f64 / per_span as f64; + points.push(mix(f, x.at(f), y.at(f))); + } + } + } Some(WidthProfile { points }) } +/// Whether width profile `w` has a discontinuous point at `p` (two points there, within `1e-7`, +/// as [`WidthProfile::around`] reads them). +fn steps_at(w: &WidthProfile, p: f64) -> bool { + let i = w.points.partition_point(|q| q.0 < p - 1e-7); + w.points.get(i..).unwrap_or_default().iter().take_while(|q| q.0 <= p + 1e-7).count() > 1 +} + /// The positions along the stroke where either of two pen recordings has a sample, in order. fn sample_positions(x: Option<&[(f64, f64)]>, y: Option<&[(f64, f64)]>) -> Vec<f64> { let mut at: Vec<f64> = x.unwrap_or_default().iter().chain(y.unwrap_or_default()).map(|p| p.0).filter(|p| p.is_finite()).collect(); at.sort_by(f64::total_cmp); - at.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + // Within the distance `step_after` reads a step at, so a sample near a step keeps the step. + at.dedup_by(|a, b| (*a - *b).abs() <= 1e-9); if at.is_empty() { at = vec![0.0, 1.0]; } @@ -482,6 +506,161 @@ fn reverse_closed(sp: &mut SubPath) { sp.anchors.rotate_right(1); } +/// How a key's subpaths were turned for its blend, so that its stroke's profiles, pen +/// recordings and arrowheads turn with it: position `u` along the turned subpath is position +/// `start + u` (`start - u` when `reversed`) along the key's own, wrapping round a closed one. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct Orient { + start: f64, + reversed: bool, + closed: bool, +} + +impl Orient { + fn of(sp: &SubPath) -> Self { + Self { closed: sp.closed, ..Self::default() } + } + + /// The subpath now starts at the fraction `f` of its length. + fn rotate(&mut self, f: f64) { + self.start += if self.reversed { -f } else { f }; + } + + /// The subpath now runs the other way. + fn reverse(&mut self) { + self.start += if self.reversed { -1.0 } else { 1.0 }; + self.reversed = !self.reversed; + } + + /// As [`start_at`] turns `sp` (call it before). + fn start_at(&mut self, sp: &SubPath, i: usize) { + let n = sp.anchors.len(); + if i >= n { + return; + } + if sp.closed { + self.rotate(arc_fraction(sp, i)); + } else if i + 1 == n && n > 1 { + self.reverse(); + } + } + + /// Where a closed subpath now starts, as a fraction of its length; 0 for an open one. + fn seam(&self) -> f64 { + let s = if self.closed { self.start.rem_euclid(1.0) } else { 0.0 }; + if s > 1e-9 && s < 1.0 - 1e-9 { s } else { 0.0 } + } + + fn is_identity(&self) -> bool { + !self.reversed && self.seam() == 0.0 + } + + /// Whether two subpaths were turned alike, so one stroke along both turns the same way. + fn same_turn(&self, o: &Self) -> bool { + self.reversed == o.reversed && (self.seam() - o.seam()).abs() < 1e-9 + } +} + +/// How far along `sp` its anchor `i` sits, as a fraction of its length. +fn arc_fraction(sp: &SubPath, i: usize) -> f64 { + let pos = sp.anchor_arc_positions(); + match (pos.get(i), pos.last()) { + (Some(&at), Some(&total)) if total.is_finite() && total > 1e-12 => (at / total).clamp(0.0, 1.0), + _ => 0.0, + } +} + +/// Samples (position, value) along a key's subpath, read along the subpath turned as `o` says. +/// A closed subpath starting elsewhere starts the samples there too: the ones before it move +/// past the seam (`wrap` applied), which keeps any step the seam makes, and a value between +/// samples is read with `at`. A reversed subpath then reads them backwards (`flip` applied). +fn reoriented<V: Copy>(points: &[(f64, V)], o: Orient, at: impl Fn(f64) -> V, wrap: impl Fn(V) -> V, flip: impl Fn(V) -> V) -> Vec<(f64, V)> { + let s = o.seam(); + let mut out = points.to_vec(); + if s > 0.0 + && let (Some(&(_, first)), Some(&(_, last))) = (points.first(), points.last()) + { + let i = points.partition_point(|q| q.0 < s - 1e-9); + let j = points.partition_point(|q| q.0 <= s + 1e-9).max(i); + let (before, after) = match points.get(i..j) { + Some([p, .., q]) => (p.1, q.1), + Some([p]) => (p.1, p.1), + _ => (at(s), at(s)), + }; + out = Vec::with_capacity(points.len() + 4); + out.push((0.0, after)); + out.extend(points.get(j..).unwrap_or_default().iter().map(|&(t, v)| (t - s, v))); + out.push((1.0 - s, last)); + out.push((1.0 - s, wrap(first))); + out.extend(points.get(..i).unwrap_or_default().iter().map(|&(t, v)| (t + 1.0 - s, wrap(v)))); + out.push((1.0, wrap(before))); + } + if o.reversed { + out = out.into_iter().rev().map(|(t, v)| (1.0 - t, flip(v))).collect(); + } + out +} + +/// A key's stroke read along its subpath turned as `o` says: its width profile (whose sides swap +/// when reversed), pen pressure, tilt, bearing and rotation, and on a reversed open path its +/// arrowheads. +fn reorient_stroke(s: &mut StrokeLayer, o: Orient) { + if o.is_identity() { + return; + } + let same = |v: f64| v; + if let Some(p) = &mut s.profile { + let mut pts: Vec<(f64, (f64, f64))> = p.points.iter().map(|&(t, l, r)| (t, (l, r))).collect(); + // A width profile is a smooth curve through its points: where the new start splits a span, + // samples of that span keep its curve once its ends become ends of the profile. + let seam = o.seam(); + let i = p.points.partition_point(|q| q.0 < seam); + if seam > 0.0 + && let (Some(a), Some(b)) = (i.checked_sub(1).and_then(|j| p.points.get(j)), p.points.get(i)) + && seam - a.0 > 1e-9 + && b.0 - seam > 1e-9 + { + const SAMPLES: usize = 32; + let (a, b) = (a.0, b.0); + let extra: Vec<(f64, (f64, f64))> = + (1..SAMPLES).map(|k| a + (b - a) * k as f64 / SAMPLES as f64).filter(|f| (f - seam).abs() > 1e-9).map(|f| (f, p.at(f))).collect(); + pts.splice(i..i, extra); + } + let out = reoriented(&pts, o, |f| p.at(f), |v| v, |(l, r)| (r, l)); + p.points = out.into_iter().map(|(t, (l, r))| (t, l, r)).collect(); + } + for p in [&mut s.pressure, &mut s.tilt].into_iter().flatten() { + let out = reoriented(&p.points, o, |f| p.at(f), same, same); + p.points = out; + } + for p in [&mut s.bearing, &mut s.rotation].into_iter().flatten() { + // The samples moved past the seam carry the whole turns the recording makes along the + // stroke, so the angle does not jump by them there. + let turns = match (p.points.first(), p.points.last()) { + (Some(a), Some(b)) if (b.1 - a.1).is_finite() => ((b.1 - a.1) / 360.0).round() * 360.0, + _ => 0.0, + }; + let out = reoriented(&p.points, o, |f| p.unwrapped_at(f), |v| v + turns, same); + p.points = out; + } + if o.reversed && !o.closed { + std::mem::swap(&mut s.start_arrow, &mut s.end_arrow); + s.arrow_scale = (s.arrow_scale.1, s.arrow_scale.0); + } +} + +/// `n` with every stroke read along its subpaths turned as `o` says. +fn reorient_strokes(n: &mut Node, o: Orient) { + if o.is_identity() { + return; + } + for it in n.appearance.items.iter_mut() { + if let AppearanceItem::Stroke(s) = it { + reorient_stroke(s, o); + } + } +} + /// Anchor positions of a subpath relative to its box, scaled to unit size. fn normalised(sp: &SubPath) -> Vec<Vec2> { let pts = sp.anchors.iter().map(|a| a.p); @@ -519,6 +698,8 @@ fn best_rotation(a: &SubPath, b: &SubPath) -> usize { pub struct PathPair { a: Vec<SubPath>, b: Vec<SubPath>, + /// How each path's subpaths were turned, where all of them turned alike (else unturned). + orient: (Orient, Orient), } impl PathPair { @@ -530,6 +711,7 @@ impl PathPair { let cb = b.bounds().map(|r| r.center()).unwrap_or_default(); let degenerate = |like: &SubPath, c: Point| SubPath::new(vec![Anchor::corner(c); like.anchors.len().max(1)], like.closed); let (mut va, mut vb) = (Vec::with_capacity(n), Vec::with_capacity(n)); + let mut orient = (Orient::default(), Orient::default()); for i in 0..n { let (mut sa, mut sb) = match (a.subpaths.get(i), b.subpaths.get(i)) { (Some(x), Some(y)) => (x.clone(), y.clone()), @@ -537,17 +719,21 @@ impl PathPair { (None, Some(y)) => (degenerate(y, ca), y.clone()), (None, None) => continue, }; + let (mut oa, mut ob) = (Orient::of(&sa), Orient::of(&sb)); let explicit = i == 0 && (starts.0.is_some() || starts.1.is_some()); if explicit { if let Some(s) = starts.0 { + oa.start_at(&sa, s); start_at(&mut sa, s); } if let Some(s) = starts.1 { + ob.start_at(&sb, s); start_at(&mut sb, s); } } let closed = sa.closed && sb.closed && sa.anchors.len() > 2 && sb.anchors.len() > 2; if closed && (sa.area() * sb.area()) < 0.0 { + ob.reverse(); reverse_closed(&mut sb); } let m = sa.anchors.len().max(sb.anchors.len()); @@ -555,12 +741,26 @@ impl PathPair { grow(&mut sb, m); if closed && !explicit { let r = best_rotation(&sa, &sb); + if r != 0 { + ob.rotate(arc_fraction(&sb, r)); + } sb.anchors.rotate_left(r); } + // One stroke runs along every subpath of a key, each reading it by its own length, so + // it turns only when all of the key's subpaths turned alike. + if i == 0 { + orient = (oa, ob); + } + if i < a.subpaths.len() && !orient.0.same_turn(&oa) { + orient.0 = Orient::default(); + } + if i < b.subpaths.len() && !orient.1.same_turn(&ob) { + orient.1 = Orient::default(); + } va.push(sa); vb.push(sb); } - Self { a: va, b: vb } + Self { a: va, b: vb, orient } } /// The path at `t` (0 = `a`, 1 = `b`). @@ -675,7 +875,12 @@ impl Lerp { _ => Shape::Other, }, }; - Self { a: a.clone(), b: b.clone(), shape } + let (mut a, mut b) = (a.clone(), b.clone()); + if let Shape::Path { pair, .. } = &shape { + reorient_strokes(&mut a, pair.orient.0); + reorient_strokes(&mut b, pair.orient.1); + } + Self { a, b, shape } } /// The object at `t` (0 = `a`, 1 = `b`), with id 0. @@ -1592,6 +1797,7 @@ pub fn blend_expand(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<Node> { #[cfg(test)] mod tests { use super::*; + use crate::appearance::Arrowhead; use vectorcraft_geom::{Rect, shapes}; #[test] @@ -1885,6 +2091,89 @@ mod tests { assert!(s.pressure.is_none() && s.tilt.is_none() && s.bearing.is_none() && s.rotation.is_none()); } + #[test] + fn a_point_near_the_other_keys_step_keeps_the_step() { + let flat = PressureProfile { points: vec![(0.0, 0.0), (0.4999995, 0.0), (1.0, 0.0)] }; + let stepped = PressureProfile { points: vec![(0.0, 0.0), (0.5, 0.0), (0.5, 1.0), (1.0, 1.0)] }; + let p = lerp_pressure(Some(&flat), Some(&stepped), 1.0).unwrap_or_default(); + assert!((p.at(0.75) - 1.0).abs() < 1e-9, "{p:?}"); + let narrow = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.4999995, 1.0, 1.0), (1.0, 1.0, 1.0)] }; + let stepped = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 1.0, 1.0), (0.5, 0.0, 0.0), (1.0, 0.0, 0.0)] }; + let w = lerp_profile(Some(&narrow), Some(&stepped), 1.0).unwrap_or_default(); + assert!(w.at(0.75).0.abs() < 1e-9, "{w:?}"); + // The other key's step doesn't reshape a smooth profile's curve. + let early = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.25, 1.0, 1.0), (0.25, 0.5, 0.5), (1.0, 0.5, 0.5)] }; + let w = lerp_profile(Some(&WidthProfile::lens()), Some(&early), 0.0).unwrap_or_default(); + for u in [0.1, 0.125, 0.2, 0.3, 0.4] { + let (got, want) = (w.at(u).0, WidthProfile::lens().at(u).0); + assert!((got - want).abs() < 5e-3, "at {u}: {got} for {want}: {w:?}"); + } + let lens = WidthProfile::lens(); + assert_eq!(lerp_profile(Some(&lens), Some(&lens), 0.5).and_then(|p| p.preset_id()), Some("lens")); + } + + #[test] + fn stroke_profiles_turn_with_a_key_path_started_elsewhere() { + let keyed = |path: PathData, edit: &dyn Fn(&mut StrokeLayer)| { + let mut ap = Appearance::default(); + ap.set_stroke(Paint::solid(Color::BLACK)); + if let Some(s) = ap.stroke_mut() { + edit(s); + } + Node::path(NodeId(1), path, ap) + }; + let stroke = |n: &Node| n.appearance.stroke().cloned().unwrap_or_else(|| panic!("a stroke")); + let pressure = |s: &StrokeLayer, f: f64| s.pressure.as_ref().map_or(PressureProfile::MID, |p| p.at(f)); + // An open key clicked at its last anchor runs the other way, and so do its pressure, its + // width profile (whose sides swap) and its arrowheads. + let line = |x: f64| PathData::single(SubPath::polyline(&[Point::new(x, 0.0), Point::new(x + 10.0, 0.0)], false)); + let light = keyed(line(0.0), &|s| s.pressure = Some(PressureProfile { points: vec![(0.0, 0.0), (1.0, 0.0)] })); + let ramp = keyed(line(100.0), &|s| { + s.pressure = Some(PressureProfile { points: vec![(0.0, 1.0), (1.0, 0.0)] }); + s.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 0.0), (1.0, 0.0, 0.0)] }); + s.start_arrow = Some(Arrowhead::Triangle); + s.arrow_scale = (200.0, 100.0); + }); + let blend = Lerp::new(&light, &ramp, (None, Some(1))); + let s = stroke(&blend.at(0.25)); + assert!(pressure(&s, 0.0).abs() < 1e-9 && (pressure(&s, 1.0) - 0.25).abs() < 1e-9, "{:?}", s.pressure); + let s = stroke(&blend.at(1.0)); + assert_eq!(s.profile.as_ref().map(|p| p.at(1.0)), Some((0.0, 1.0)), "{:?}", s.profile); + assert_eq!((s.start_arrow, s.end_arrow, s.arrow_scale), (None, Some(Arrowhead::Triangle), (100.0, 200.0))); + // A key whose other subpaths keep their direction keeps its one stroke as it is. + let two = |x: f64| { + let sub = |y: f64| SubPath::polyline(&[Point::new(x, y), Point::new(x + 10.0, y)], false); + PathData::new(vec![sub(0.0), sub(20.0)]) + }; + let ramps = keyed(two(100.0), &|s| s.pressure = Some(PressureProfile { points: vec![(0.0, 1.0), (1.0, 0.0)] })); + let s = stroke(&Lerp::new(&keyed(two(0.0), &|_| {}), &ramps, (None, Some(1))).at(1.0)); + assert!((pressure(&s, 0.0) - 1.0).abs() < 1e-9, "{:?}", s.pressure); + // A closed key started half way round starts its recordings there too, keeping the step + // where its own start was and the whole turn its rotation makes. + let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)); + let plain = keyed(square(0.0), &|_| {}); + let turning = keyed(square(100.0), &|s| { + s.pressure = Some(PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] }); + s.rotation = Some(AngleProfile { points: vec![(0.0, 0.0), (1.0, 360.0)] }); + s.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 0.0, 0.0)] }); + }); + let blend = Lerp::new(&plain, &turning, (Some(0), Some(2))); + let s = stroke(&blend.at(1.0)); + assert!((pressure(&s, 0.25) - 0.75).abs() < 1e-9 && (pressure(&s, 0.75) - 0.25).abs() < 1e-9, "{:?}", s.pressure); + let width = s.profile.as_ref().map_or((1.0, 1.0), |p| p.at(0.75)); + assert!((width.0 - 0.75).abs() < 1e-6 && (width.1 - 0.75).abs() < 1e-6, "the width steps where the key started: {:?}", s.profile); + // A width curve split by the new start keeps its shape. + let lensed = keyed(square(100.0), &|s| s.profile = Some(WidthProfile::lens())); + let s = stroke(&Lerp::new(&plain, &lensed, (Some(0), Some(1))).at(1.0)); + for u in [0.05, 0.125, 0.2, 0.6, 0.7, 0.74, 0.76, 0.8, 0.875, 0.95] { + let (got, want) = (s.profile.as_ref().map_or(1.0, |p| p.at(u).0), WidthProfile::lens().at((u + 0.25) % 1.0).0); + assert!((got - want).abs() < 2e-3, "at {u}: {got} for {want}: {:?}", s.profile); + } + let rot = |t: f64, f: f64| stroke(&blend.at(t)).rotation.as_ref().map_or(0.0, |p| p.unwrapped_at(f)); + assert!((vectorcraft_geom::normalize_deg(rot(1.0, 0.25)) + 90.0).abs() < 1e-6, "{:?}", s.rotation); + assert!((rot(0.5, 0.499) - rot(0.5, 0.501)).abs() < 1.0, "no jump where the key started: {:?}", stroke(&blend.at(0.5)).rotation); + } + #[test] fn odd_dash_blends_to_solid_with_closed_gaps() { let d = lerp_dash(Some(&Dash { pattern: vec![2.0, 3.0, 4.0], ..Default::default() }), None, 1.0).unwrap(); diff --git a/docs/mcp.md b/docs/mcp.md index ee40e202c7..096b835b95 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8940,7 +8940,8 @@ so its point at `t` moves by (dx, dy), turning a straight spine into a curve (`p How blends interpolate: closed shapes start where they twist least unless `starts` picks the anchors; strokes interpolate weight, dashes (a solid stroke counts as the dashed one with its gaps closed), miter limit, arrowhead scale, width profiles and the pen pressure, tilt, bearing and rotation recorded with the Paintbrush (a stroke without a -recording counts as half pressure, half tilt and 0°, as brushes read it; angles turn the shorter way round), while +recording counts as half pressure, half tilt and 0°, as brushes read it; angles turn the shorter way round; a key's +profiles, recordings and arrowheads turn with its path when the blend starts it at another anchor), while caps, joins, alignment, arrowheads and brushes switch halfway; gradients of one kind with different stop counts resample to a common set of stops. Groups pair their members in stacking order (members only one group has grow out of the other's centre), compound paths stay compound paths, and type, symbol instances and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0bd1e51d6b..0d02c01bf8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1014,7 +1014,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). A link toggle behind Corner Radius (the `singleCornerRadius` preference) switches the panels to a field for each corner of a live rectangle: Top Left, Top Right, Bottom Right and Bottom Left. Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two sides meet at an angle at an anchor that isn't smooth: curved sides too, trimmed to what the cut leaves of them (#1144). Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply. With Direct Selection a click on a widget (no drag) selects its corner, as a click on the corner's anchor does, so the next drag on it rounds that corner alone; Shift-click toggles it (#1239). While a corner widget is dragged with the Selection tool the bounding box hides, so the rounding shows against the path alone (#1229). A round corner next to a curved side curves smoothly instead of sticking out of the shape (#1547, fixes #1512). View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them. - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). - - **Blend steps of brushed strokes:** the steps interpolate the pen pressure, tilt, bearing and rotation recorded with the Paintbrush, so a Calligraphic, Scatter or Art brush that varies with the pen changes gradually from one key to the next. A key without a recording counts as half pressure, half tilt and 0°, as brushes read it, and angles turn the shorter way round. Different brushes still switch halfway (VectorCraft's own rule; strokes blend their options, as [Blended objects overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; `crates/doc/src/blend.rs`, `pen_pressure_tilt_and_angles_interpolate_for_brushes`). + - **Blend steps of brushed strokes:** the steps interpolate the pen pressure, tilt, bearing and rotation recorded with the Paintbrush, so a Calligraphic, Scatter or Art brush that varies with the pen changes gradually from one key to the next. A key without a recording counts as half pressure, half tilt and 0°, as brushes read it, and angles turn the shorter way round. When the blend starts a key's path at another anchor (an open path run from its end, a closed one turned to another start), the key's recordings, width profile and arrowheads turn with the path. Different brushes still switch halfway (VectorCraft's own rule; strokes blend their options, as [Blended objects overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; `crates/doc/src/blend.rs`, `pen_pressure_tilt_and_angles_interpolate_for_brushes`, `stroke_profiles_turn_with_a_key_path_started_elsewhere`). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. From 092f28feb33a4a9d4ebe1d7da445a007af6972c6 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 08:53:26 -0800 Subject: [PATCH 72/92] Canvas: each document keeps its own placed-bitmap value in the frame key The canvas cache key held the process-wide placed-document bitmap generation, so a bitmap made for a file that only another document holds changed every document's key, and the next frame differed from the frame shown in more than the view. In the web build, which has no render worker, a heavy canvas then rendered in the middle of a pan instead of waiting until the view was still. On desktop the pan dropped its low-resolution fill, and the render worker did not copy pixels from its last frame. Each document now keeps its own value, checked once per generation change, as the plug-in key is. The value moves to the current generation when the document shows a placed document of a file whose bitmap was made. A file kept only for Undo does not count. When more bitmaps were made since the last check than are recorded, the value moves when the document shows a placed document of one of its images. After the value moves, the part of the frame to draw again comes from the bitmaps made since the last check before the move, so bitmaps made earlier for other documents no longer make the whole frame render. The canvas tests that retried or tolerated a new frame when another test made a bitmap now assert without those workarounds. --- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/caches.rs | 21 +- crates/ui-egui/src/canvas.rs | 84 +++++++- crates/ui-egui/src/canvas/panes.rs | 7 +- crates/ui-egui/src/canvas/panes/tests.rs | 16 ++ crates/ui-egui/src/canvas/raster.rs | 7 +- crates/ui-egui/src/canvas/tests.rs | 244 ++++++++++++++++++----- crates/ui-egui/src/lib.rs | 2 +- 8 files changed, 322 insertions(+), 60 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 7865375b69..8f7b74a6c4 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -89,6 +89,7 @@ impl VectorcraftApp { selection_bounds: None, content_bounds: None, plugin_keys: Default::default(), + placed_keys: Default::default(), cursors: Default::default(), view_moved: None, hover_cursor: None, diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs index 38b790ecc4..7f0d73511d 100644 --- a/crates/ui-egui/src/caches.rs +++ b/crates/ui-egui/src/caches.rs @@ -61,6 +61,8 @@ pub struct CanvasCache { /// [`CacheKey::plugins`] by document uid, with the plug-in revision /// ([`vectorcraft_render::effects::plugin_revision`]) it was last checked at. pub plugin_keys: std::collections::HashMap<u64, (u64, u64)>, + /// [`CacheKey::placed`] by document uid ([`canvas::placed_key`]). + pub placed_keys: std::collections::HashMap<u64, PlacedKey>, /// The tools' cursors as OS cursor bitmaps. pub cursors: cursors::Images, /// The view the canvas last drew and when it last changed (egui's input time): while it moves, @@ -82,6 +84,20 @@ pub struct CanvasCache { pub windows: crate::doc_windows::Windows, } +/// [`CanvasCache::placed_keys`]: a document's [`CacheKey::placed`] and the placed-document bitmap +/// generations ([`vectorcraft_render::placed_document::generation`]) it was checked at. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PlacedKey { + /// The last generation the document was checked at. + pub checked: u64, + /// The document's [`CacheKey::placed`]: the generation of its first check, or of the last check + /// that found a newly made bitmap the document may show. + pub key: u64, + /// The value before it last changed, and the last generation that value was checked at: no + /// bitmap of a placed document the document shows was made between the two. + pub before: (u64, u64), +} + /// [`CanvasCache::slices`]: the layout of the slices of (document uid, revision). pub type SliceCache = ((u64, u64), std::sync::Arc<Vec<vectorcraft_doc::SliceArea>>); @@ -120,7 +136,10 @@ pub struct CacheKey { pub highlight_alternate_glyphs: bool, /// Type › Highlight Substituted Fonts. pub highlight_substituted_fonts: bool, - /// [`vectorcraft_render::placed_document::generation`]: placed documents' bitmaps made since. + /// A [`vectorcraft_render::placed_document::generation`] that changes when the frame may show a + /// newly made bitmap of a placed document ([`canvas::placed_key`]). Bitmaps of files that only + /// other documents hold leave it as it is, unless more bitmaps were made since the last check + /// than are recorded and the document shows a placed document. pub placed: u64, /// The plug-in revision ([`vectorcraft_render::effects::plugin_revision`]) of the last plug-in /// install or removal, or of the last time deferred plug-in effect stages gave results, after diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 202a0542a8..f1bf96bd6d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -327,9 +327,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // their pixels as they rasterize, hard-edged. let smooth_images = pixel.is_none() || app.session.prefs.anti_aliased_bitmaps; let plugins = plugin_key(&mut app.canvas.plugin_keys, &app.session, st); + let placed = placed_key(&mut app.canvas.placed_keys, &app.session, st); // The frame renders the view at its zoom in the interface's points, as `xf` maps it. let drawn = v.on_screen(app.canvas_ui_zoom); - let key = cache_key(app, ui, st, &t, &drawn, (w, h, ppp), (pixel, smooth_images, plugins)); + let key = cache_key(app, ui, st, &t, &drawn, (w, h, ppp), (pixel, smooth_images, plugins, placed)); // Placed documents' bitmaps are being made: draw again when they are ready. if vectorcraft_render::placed_document::busy() { ui.ctx().request_repaint_after(std::time::Duration::from_millis(30)); @@ -579,7 +580,7 @@ fn cache_key( t: &Tokens, v: &View, (w, h, ppp): (u32, u32, f32), - (pixel, smooth_images, plugins): (Option<[i64; 4]>, bool, u64), + (pixel, smooth_images, plugins, placed): (Option<[i64; 4]>, bool, u64, u64), ) -> CacheKey { // Paths made into guides are drawn as the ruler guides are (Preferences › Guides & Grid). let guide_look = LineLook::guides(&app.session.prefs, t); @@ -603,7 +604,7 @@ fn cache_key( anti_alias: app.session.prefs.anti_aliased_artwork, highlight_alternate_glyphs: app.session.prefs.highlight_alternate_glyphs, highlight_substituted_fonts: app.session.prefs.highlight_substituted_fonts, - placed: vectorcraft_render::placed_document::generation(), + placed, plugins, pixel, smooth_images, @@ -637,6 +638,83 @@ fn plugin_key( } } +/// [`CacheKey::placed`] of document `st`, one of the documents open in `session`. `keys` holds each +/// document's value and the placed-document bitmap generations it was checked at +/// ([`vectorcraft_render::placed_document::generation`]). +fn placed_key( + keys: &mut std::collections::HashMap<u64, crate::PlacedKey>, + session: &vectorcraft_engine::Session, + st: &vectorcraft_engine::DocState, +) -> u64 { + placed_key_at(keys, session, st, vectorcraft_render::placed_document::generation(), vectorcraft_render::placed_document::made_between) +} + +/// [`placed_key`] at generation `now`, with `made` giving the files whose bitmaps were made between +/// two generations ([`vectorcraft_render::placed_document::made_between`]). The value becomes `now` +/// when `st` shows a placed document of a file whose bitmap was made since the last check, or when +/// `made` no longer knows which files they were and `st` shows a placed document of a file in its +/// images, as [`vectorcraft_render::placed_area`] decides (every file shows while a pattern is +/// edited). Otherwise it stays: a bitmap made for another document, or for a file `st` keeps only +/// for Undo, leaves this document's frame as it is. A document checked for the first time takes +/// `now`. +fn placed_key_at( + keys: &mut std::collections::HashMap<u64, crate::PlacedKey>, + session: &vectorcraft_engine::Session, + st: &vectorcraft_engine::DocState, + now: u64, + made: impl FnOnce(u64, u64) -> Option<std::collections::HashSet<String>>, +) -> u64 { + let entry = match keys.get(&st.uid).copied() { + Some(p) if p.checked == now => return p.key, + None => crate::PlacedKey { checked: now, key: now, before: (now, now) }, + Some(p) => { + let images = &st.doc.images; + // The files made that `st` holds: a placed document of one of them may show. + let files: std::collections::HashSet<String> = if images.is_empty() { + Default::default() + } else { + match made(p.checked, now) { + Some(files) => files.into_iter().filter(|f| images.contains_key(f)).collect(), + None => images.keys().cloned().collect(), + } + }; + let stays = matches!(vectorcraft_render::placed_area(&st.doc, &files), vectorcraft_render::Changed::Nothing); + if stays { crate::PlacedKey { checked: now, ..p } } else { crate::PlacedKey { checked: now, key: now, before: (p.key, p.checked) } } + } + }; + keys.retain(|uid, _| session.documents().iter().any(|d| d.uid == *uid)); + keys.insert(st.uid, entry); + entry.key +} + +/// The generation after which the bitmaps made decide what a frame shown at [`CacheKey::placed`] +/// value `shown` draws again at value `key`, with `entry` the document's [`crate::PlacedKey`]. When +/// the value changed once since `shown`, it is the last generation `shown` was checked at: no +/// bitmap of the document's placed documents was made between `shown` and that generation. +/// Otherwise it is `shown`. +fn placed_since(entry: Option<&crate::PlacedKey>, shown: u64, key: u64) -> u64 { + entry.filter(|p| p.key == key && p.before.0 == shown).map_or(shown, |p| p.before.1) +} + +/// What placed documents' bitmaps change in `doc` between a frame shown at [`CacheKey::placed`] +/// value `shown` and the frame at value `key`: where `doc` shows the files whose bitmaps were made +/// after [`placed_since`], with `entry` the document's [`crate::PlacedKey`] and `made` giving the +/// files whose bitmaps were made between two generations +/// ([`vectorcraft_render::placed_document::made_between`]). None when those files are no longer +/// known. +fn placed_changes( + entry: Option<&crate::PlacedKey>, + shown: u64, + key: u64, + doc: &vectorcraft_doc::Document, + made: impl FnOnce(u64, u64) -> Option<std::collections::HashSet<String>>, +) -> Option<vectorcraft_render::Changed> { + if shown == key { + return Some(vectorcraft_render::Changed::Nothing); + } + Some(vectorcraft_render::placed_area(doc, &made(placed_since(entry, shown, key), key)?)) +} + /// Once the first view of a document has been drawn, the status bar says how many plug-in effect /// stages it left deferred, if any. fn report_deferred(app: &mut VectorcraftApp, effects: &vectorcraft_plugins::deferred::Deferrals) { diff --git a/crates/ui-egui/src/canvas/panes.rs b/crates/ui-egui/src/canvas/panes.rs index fb305d29dd..fc440be673 100644 --- a/crates/ui-egui/src/canvas/panes.rs +++ b/crates/ui-egui/src/canvas/panes.rs @@ -12,8 +12,8 @@ use super::outline::{c32, node_outline, stroke_path, too_many_anchors}; use super::view::fit_zoom; use super::{ Affine, CacheKey, Point, Rect, RulerScale, Sense, Stroke, StrokeKind, Tokens, Ui, VectorcraftApp, View, Xf, artboard_edges, cache_key, - dim_on_canvas, frame_options, frame_view, grid, guides_of, json, paint_paper, pixel_region, plugin_key, pos2, rulers, shows_artboards, - textured_quad, vec2, view_rects, + dim_on_canvas, frame_options, frame_view, grid, guides_of, json, paint_paper, pixel_region, placed_key, plugin_key, pos2, rulers, + shows_artboards, textured_quad, vec2, view_rects, }; use crate::doc_windows::Win; use crate::thumb_worker::ThumbWorker; @@ -305,8 +305,9 @@ pub(crate) fn pane(app: &mut VectorcraftApp, ui: &Ui, w: Win, cell: egui::Rect) let pixel = (app.ui.view.pixel_preview && xf.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten(); let smooth_images = pixel.is_none() || app.session.prefs.anti_aliased_bitmaps; let plugins = plugin_key(&mut app.canvas.plugin_keys, &app.session, st); + let placed = placed_key(&mut app.canvas.placed_keys, &app.session, st); let drawn = v.on_screen(ui_zoom); - let mut key = cache_key(app, ui, st, &t, &drawn, (pw, ph, ppp), (pixel, smooth_images, plugins)); + let mut key = cache_key(app, ui, st, &t, &drawn, (pw, ph, ppp), (pixel, smooth_images, plugins, placed)); // The current document's drag and Image Trace hold are not this document's, and this view has // its own Outline or Preview. (key.dragging, key.trace_source, key.outline) = (false, None, v.outline); diff --git a/crates/ui-egui/src/canvas/panes/tests.rs b/crates/ui-egui/src/canvas/panes/tests.rs index 9dc008a896..cb1394f612 100644 --- a/crates/ui-egui/src/canvas/panes/tests.rs +++ b/crates/ui-egui/src/canvas/panes/tests.rs @@ -323,6 +323,22 @@ fn other_panes_show_pixel_preview() { assert!(x1 > x0 && y1 > y0); } +/// A pane's render key takes its document's [`CacheKey::placed`] value ([`placed_key`]), which a +/// bitmap made for a test running beside this one does not move: the document holds no images. +#[test] +fn a_panes_key_takes_its_documents_placed_value() { + let mut app = app_with(2); + let ctx = egui::Context::default(); + app.run("window.arrange.tile", json!({})).unwrap(); + let uid = app.session.documents()[0].uid; + let generation = vectorcraft_render::placed_document::generation(); + let marked = generation.wrapping_add(1000); + app.canvas.placed_keys.insert(uid, crate::PlacedKey { checked: generation, key: marked, before: (marked, generation) }); + frame(&mut app, &ctx, vec![]); + let key = app.canvas.panes.shown.get(&(uid, 1)).map(|(k, _)| k.placed); + assert_eq!(key, Some(marked)); +} + #[test] fn a_resized_window_fits_the_tiled_documents_again_unless_one_was_zoomed() { let mut app = app_with(3); diff --git a/crates/ui-egui/src/canvas/raster.rs b/crates/ui-egui/src/canvas/raster.rs index 64fc3599f5..2722bc79fe 100644 --- a/crates/ui-egui/src/canvas/raster.rs +++ b/crates/ui-egui/src/canvas/raster.rs @@ -57,11 +57,8 @@ pub(super) fn changed_pixels( return None; } // The placed documents' files whose bitmaps were made since the frame shown. - let placed = if shown_key.placed == key.placed { - vectorcraft_render::Changed::Nothing - } else { - vectorcraft_render::placed_area(doc, &vectorcraft_render::placed_document::made_between(shown_key.placed, key.placed)?) - }; + let entry = app.session.documents().iter().find(|d| d.uid as usize == key.doc).and_then(|d| app.canvas.placed_keys.get(&d.uid)); + let placed = super::placed_changes(entry, shown_key.placed, key.placed, doc, vectorcraft_render::placed_document::made_between)?; match app.canvas.renderer.changed(shown, doc, opts).and(placed) { vectorcraft_render::Changed::Nothing => Some(None), vectorcraft_render::Changed::Area(area) => match vectorcraft_render::view_pixels(area, view, w, h) { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 66f3f1423c..04a9f2e4c8 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2856,13 +2856,10 @@ fn cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes() { "{old_rot}° -> {new_rot}°, DPI {old_ppp} -> {new_ppp}, pixel={pixel}: {vertex:?}" ); } - // Deliver the actual replacement and verify that it replaces the cached key. The frame - // reports a slow render: when a parallel test makes a placed document's bitmap, the key's - // process-wide `placed` generation moves, and the next frame goes to the controlled worker - // instead of rendering over the delivered frame on this thread. + // Deliver the actual replacement and verify that it replaces the cached key. let img = app.canvas.renderer.render(&job.doc, job.w, job.h, job.view, &job.opts); let expected = job.key.clone(); - completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 50.0, moved: 0, reused: false }).unwrap(); + completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 1.0, moved: 0, reused: false }).unwrap(); frame(&mut app, size, new_ppp); assert_eq!(app.canvas.key.as_ref(), Some(&expected), "the worker's frame arrived"); } @@ -2942,21 +2939,6 @@ fn a_turned_view_draws_the_last_frame_turned_until_its_own_arrives() { /// fill is dropped. #[test] fn a_pan_fills_the_area_the_last_frame_leaves_until_the_worker_frame_arrives() { - // The canvas key holds the global placed-document bitmap clock, which tests on other threads - // move: a frame whose key changed by more than the view drops the fill. An attempt that fails - // while the clock moved runs again; one that fails while it stood still is a real failure. - for _ in 0..20 { - let clock = vectorcraft_render::placed_document::generation(); - match std::panic::catch_unwind(pan_fills_the_area_the_last_frame_leaves) { - Ok(()) => return, - Err(_) if vectorcraft_render::placed_document::generation() != clock => {} - Err(e) => std::panic::resume_unwind(e), - } - } - pan_fills_the_area_the_last_frame_leaves(); -} - -fn pan_fills_the_area_the_last_frame_leaves() { let ctx = egui::Context::default(); let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); @@ -3185,6 +3167,200 @@ fn the_canvas_waits_only_while_the_view_of_a_slow_frame_changes() { assert_eq!(still_wait(&mut still, Some(&shown), &shown, true, 15.0), None, "the frame shown"); } +/// A bitmap made in the background for a file that only another document holds leaves this +/// document's canvas key as it is. Without a render worker (the web build's setting), a pan of a +/// heavy canvas keeps drawing the frame shown until the view is still. A light canvas renders the +/// pan at once, and its key differs from the frame shown only in the view: the pan fill and the +/// worker's pixel copy ask for that. +#[test] +fn a_bitmap_made_for_another_documents_file_leaves_a_pan_waiting() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap(); + app.canvas.worker_started = true; // no worker, as in the web build + app.canvas.waits_for_still = true; + // Another window's document places a file, which its canvas asks a bitmap of. + let mut other = VectorcraftApp::new(Session::new(), Default::default()); + other.canvas.worker_started = true; // render on this thread + let mut src = Session::new(); + src.execute("file.new", &json!({"width": 70, "height": 40})).unwrap(); + src.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 70, "height": 40})).unwrap(); + let dir = std::env::temp_dir().join(format!("vectorcraft-placed-pan-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("badge.vectorcraft"); + std::fs::write(&path, vectorcraft_format::save_file(&src.active().unwrap().doc)).unwrap(); + other.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + other.session.execute("file.place", &json!({"path": path.to_string_lossy(), "at": [100, 100]})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut VectorcraftApp| { + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + frame(&mut app); // the first frame fits the view to the artboard + frame(&mut app); + app.canvas.last_ms = 1.0; + app.view_mut().unwrap().center.x += 10.0; + frame(&mut app); + let shown = app.canvas.key.clone().unwrap(); + // The other canvas asks for the bitmap, and it is made: the generation changes before the + // bitmap stops being pending. + let generation = vectorcraft_render::placed_document::generation(); + shapes(&mut other, &egui::Context::default()); + while vectorcraft_render::placed_document::busy() { + std::thread::yield_now(); + } + assert_ne!(vectorcraft_render::placed_document::generation(), generation, "a bitmap was made"); + app.canvas.last_ms = 50.0; + app.view_mut().unwrap().center.x += 10.0; + frame(&mut app); + assert_eq!(app.canvas.key.as_ref(), Some(&shown), "a heavy canvas is not rendered while the view moves"); + app.canvas.last_ms = 1.0; + app.view_mut().unwrap().center.x += 10.0; + frame(&mut app); + let next = app.canvas.key.clone().unwrap(); + assert_eq!(next.cx, app.view().unwrap().center.x, "a light canvas renders at once"); + assert!(super::cursor::only_view_differs(&shown, &next), "only the view differs: {shown:?} {next:?}"); + std::fs::remove_dir_all(&dir).ok(); +} + +/// A document's [`CacheKey::placed`] moves to the current placed-document bitmap generation only +/// when a bitmap of a file it shows in a placed document was made, or when the files made are no +/// longer known and it shows a placed document. A bitmap of another document's file, or of a file +/// it keeps only for Undo, leaves it, so a pan of a heavy +/// document keeps waiting for the view to stop. Each change keeps the value before it and the +/// last generation that value was checked at. A closed document's value is dropped. +#[test] +fn a_documents_placed_key_moves_only_for_bitmaps_of_its_own_files() { + let mut session = Session::new(); + session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + let plain = session.active().unwrap().uid; + let mut src = Session::new(); + src.execute("file.new", &json!({"width": 60, "height": 30})).unwrap(); + src.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 60, "height": 30})).unwrap(); + let dir = std::env::temp_dir().join(format!("vectorcraft-placed-key-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("card.vectorcraft"); + std::fs::write(&path, vectorcraft_format::save_file(&src.active().unwrap().doc)).unwrap(); + session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let r = session.execute("file.place", &json!({"path": path.to_string_lossy(), "at": [60, 50]})).unwrap(); + let st = session.active().unwrap(); + let placing = st.uid; + let file = match &st.doc.node(vectorcraft_doc::NodeId(r["ids"][0].as_u64().unwrap())).unwrap().kind { + vectorcraft_doc::NodeKind::PlacedDocument(p) => p.key.clone(), + other => panic!("a placed document: {other:?}"), + }; + type Made<'a> = &'a dyn Fn(u64, u64) -> Option<std::collections::HashSet<String>>; + fn key(keys: &mut std::collections::HashMap<u64, crate::PlacedKey>, session: &Session, uid: u64, now: u64, made: Made<'_>) -> u64 { + let st = session.documents().iter().find(|d| d.uid == uid).unwrap(); + placed_key_at(keys, session, st, now, made) + } + let files = |names: &[&str]| Some(names.iter().map(|n| n.to_string()).collect::<std::collections::HashSet<_>>()); + let unasked = |_: u64, _: u64| -> Option<std::collections::HashSet<String>> { panic!("no bitmaps to look up") }; + let mut keys = std::collections::HashMap::new(); + assert_eq!(key(&mut keys, &session, placing, 5, &unasked), 5, "checked for the first time"); + assert_eq!(key(&mut keys, &session, placing, 5, &unasked), 5, "no bitmap made since"); + let other = |from: u64, to: u64| { + assert_eq!((from, to), (5, 6), "the bitmaps made since the last check"); + files(&["another document's file"]) + }; + assert_eq!(key(&mut keys, &session, placing, 6, &other), 5, "another document's file"); + assert_eq!(key(&mut keys, &session, placing, 7, &|_, _| files(&["another document's file", file.as_str()])), 7, "its own file"); + assert_eq!(keys[&placing], crate::PlacedKey { checked: 7, key: 7, before: (5, 6) }); + assert_eq!(key(&mut keys, &session, placing, 8, &|_, _| None), 8, "files no longer known"); + assert_eq!(keys[&placing].before, (7, 7)); + assert_eq!(key(&mut keys, &session, plain, 8, &unasked), 8, "the other document, checked for the first time"); + assert_eq!(key(&mut keys, &session, plain, 9, &|_, _| None), 8, "no images: files no longer known"); + assert_eq!(key(&mut keys, &session, plain, 10, &|_, _| files(&[file.as_str()])), 8, "no images: a file another document holds"); + // The same document holding an image and showing no placed document. + let mut raster = session.documents().iter().find(|d| d.uid == plain).unwrap().clone(); + let blob = vectorcraft_doc::ImageBlob { mime: "image/png".into(), bytes: Default::default(), proxy: None }; + std::sync::Arc::make_mut(&mut raster.doc).images.insert("an image".into(), blob); + assert_eq!(placed_key_at(&mut keys, &session, &raster, 11, |_, _| None), 8, "an image and no placed document: files no longer known"); + assert_eq!(keys.len(), 2); + let index = session.documents().iter().position(|d| d.uid == plain).unwrap(); + session.execute("file.close", &json!({"index": index})).unwrap(); + assert_eq!(key(&mut keys, &session, placing, 12, &|_, _| files(&[])), 8, "no bitmaps of its files"); + assert_eq!(keys.keys().copied().collect::<Vec<_>>(), [placing], "the closed document's value is dropped"); + // The placed document deleted: its file stays in the images for Undo, and no longer shows. + assert_eq!(session.active().unwrap().uid, placing); + session.execute("edit.clear", &json!({"ids": [r["ids"][0]]})).unwrap(); + assert!(session.active().unwrap().doc.images.contains_key(&file), "kept for Undo"); + assert_eq!(key(&mut keys, &session, placing, 13, &|_, _| files(&[file.as_str()])), 8, "a file kept only for Undo"); + std::fs::remove_dir_all(&dir).ok(); +} + +/// The canvas key takes the document's value from [`placed_key`], which a bitmap made for a test +/// running beside this one does not move: this document holds no images. +#[test] +fn the_canvas_key_takes_the_documents_placed_value() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap(); + app.canvas.worker_started = true; // render on this thread + let uid = app.session.active().unwrap().uid; + let generation = vectorcraft_render::placed_document::generation(); + let marked = generation.wrapping_add(1000); + app.canvas.placed_keys.insert(uid, crate::PlacedKey { checked: generation, key: marked, before: (marked, generation) }); + shapes(&mut app, &egui::Context::default()); + assert_eq!(app.canvas.key.as_ref().map(|k| k.placed), Some(marked)); +} + +/// A frame shown at one [`CacheKey::placed`] value and drawn again at the next looks at the +/// bitmaps made since the last check before the change: those made earlier were of other +/// documents' files, and past 256 bitmaps they are no longer known ([`placed_since`]). +#[test] +fn a_frame_drawn_again_after_one_change_of_its_placed_value_looks_at_the_bitmaps_since_the_last_check() { + let entry = crate::PlacedKey { checked: 400, key: 400, before: (5, 399) }; + assert_eq!(placed_since(Some(&entry), 5, 400), 399, "the value changed once since the frame shown"); + assert_eq!(placed_since(Some(&entry), 3, 400), 3, "it changed more than once"); + assert_eq!(placed_since(Some(&entry), 5, 401), 5, "a value the document no longer has"); + assert_eq!(placed_since(None, 5, 400), 5, "a document not checked"); +} + +/// A frame drawn again at a new [`CacheKey::placed`] value draws again where the document shows +/// the files whose bitmaps were made after [`placed_since`]. After one change of the value, the +/// bitmaps made before the last check of the old value are not looked up: past 256 bitmaps they +/// are no longer known, and the whole frame would render. At the same value nothing is looked up. +#[test] +fn a_frame_drawn_again_at_a_new_placed_value_draws_where_the_bitmaps_since_the_last_check_show() { + let mut session = Session::new(); + let mut src = Session::new(); + src.execute("file.new", &json!({"width": 60, "height": 30})).unwrap(); + src.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 60, "height": 30})).unwrap(); + let dir = std::env::temp_dir().join(format!("vectorcraft-placed-changes-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("card.vectorcraft"); + std::fs::write(&path, vectorcraft_format::save_file(&src.active().unwrap().doc)).unwrap(); + session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let r = session.execute("file.place", &json!({"path": path.to_string_lossy(), "at": [60, 50]})).unwrap(); + session.execute("shape.rectangle", &json!({"x": 250, "y": 200, "width": 40, "height": 30})).unwrap(); + let doc = &session.active().unwrap().doc; + let placed = doc.node(vectorcraft_doc::NodeId(r["ids"][0].as_u64().unwrap())).unwrap(); + let file = match &placed.kind { + vectorcraft_doc::NodeKind::PlacedDocument(p) => p.key.clone(), + other => panic!("a placed document: {other:?}"), + }; + let bounds = placed.geometric_bounds().unwrap(); + let entry = crate::PlacedKey { checked: 400, key: 400, before: (5, 399) }; + // Only the bitmaps made after generation 399 are known: one of the placed file. + let made = |from: u64, to: u64| (from >= 399 && to == 400).then(|| std::collections::HashSet::from([file.clone()])); + let unasked = |_: u64, _: u64| -> Option<std::collections::HashSet<String>> { panic!("no bitmaps to look up") }; + assert_eq!(placed_changes(Some(&entry), 400, 400, doc, unasked), Some(vectorcraft_render::Changed::Nothing), "the same value"); + match placed_changes(Some(&entry), 5, 400, doc, made) { + Some(vectorcraft_render::Changed::Area(a)) => { + assert!(a.x0 <= bounds.x0 && a.y0 <= bounds.y0 && a.x1 >= bounds.x1 && a.y1 >= bounds.y1, "{a:?} holds {bounds:?}"); + assert!(a.x1 < 250.0, "{a:?}: not the rectangle"); + } + other => panic!("the placed document's part: {other:?}"), + } + assert_eq!(placed_changes(Some(&entry), 3, 400, doc, made), None, "the value changed more than once: no longer known"); + let none = |_: u64, _: u64| Some(std::collections::HashSet::new()); + assert_eq!(placed_changes(Some(&entry), 5, 400, doc, none), Some(vectorcraft_render::Changed::Nothing), "no bitmaps since"); + std::fs::remove_dir_all(&dir).ok(); +} + /// File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (#394): off, Pixel /// Preview draws images with each pixel taking its nearest image pixel; on, smoothly, as the /// canvas always does out of Pixel Preview. @@ -11592,31 +11768,11 @@ fn a_pan_over_art_the_frame_size_decides_renders_whole() { } } -/// Runs `test`, whose canvas key holds the global placed-document bitmap clock, which tests on -/// other threads move: a frame whose key changed by more than the view renders whole. An attempt -/// that fails while the clock moved runs again; one that fails while it stood still is a real -/// failure. -fn while_the_placed_clock_stands_still(test: fn()) { - for _ in 0..20 { - let clock = vectorcraft_render::placed_document::generation(); - match std::panic::catch_unwind(test) { - Ok(()) => return, - Err(_) if vectorcraft_render::placed_document::generation() != clock => {} - Err(e) => std::panic::resume_unwind(e), - } - } - test(); -} - /// The canvas asks the worker for a frame that may copy pixels when the view moves, keeps the whole /// frame's time, and renders the frame whole once the view has been still for STILL_SECS, not /// before and only once. #[test] fn a_frame_with_copied_pixels_renders_whole_once_the_view_stops() { - while_the_placed_clock_stands_still(frame_with_copied_pixels_renders_whole_once_the_view_stops); -} - -fn frame_with_copied_pixels_renders_whole_once_the_view_stops() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); app.canvas.worker_started = true; @@ -11663,10 +11819,6 @@ fn frame_with_copied_pixels_renders_whole_once_the_view_stops() { /// first step of the next pan copies from it. The jobs are the ones the canvas asks for. #[test] fn a_pan_after_the_settling_frame_copies() { - while_the_placed_clock_stands_still(pan_after_the_settling_frame_copies); -} - -fn pan_after_the_settling_frame_copies() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); app.canvas.worker_started = true; @@ -11778,9 +11930,7 @@ fn a_documents_first_frame_renders_on_the_worker() { let job = jobs.try_recv().expect("the first frame is queued on the worker"); assert!(app.canvas.texture.is_none(), "not rendered on the UI thread"); frame(&mut app); - // A placed document of a test running beside this one moves the process-wide generation in - // the key: that makes a new frame, not this one again. - assert!(jobs.try_iter().all(|j| j.key.placed != job.key.placed), "not queued again while it renders"); + assert!(jobs.try_recv().is_err(), "not queued again while it renders"); assert!(app.canvas.texture.is_none()); done_tx.send(finished(&job)).unwrap(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b5964fe274..c4c6ded189 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -116,7 +116,7 @@ mod clock; mod services; pub use app::MAX_RECENT_FONTS; -pub use caches::{CacheKey, CanvasCache, LivePaintGapsCache, Perf, PrintTilingCache, SliceCache}; +pub use caches::{CacheKey, CanvasCache, LivePaintGapsCache, Perf, PlacedKey, PrintTilingCache, SliceCache}; pub use clock::{now_ms, now_secs}; pub use craft_ui::docking::Monitor as DockMonitor; #[cfg(test)] From c7780becb3b03bd1c2b0345750a8d69109977579 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 10:38:06 -0800 Subject: [PATCH 73/92] Tests: findfiles tests remove the folder trees they make tree() in crates/engine/src/cmd/findfiles/tests.rs returns a Tree guard that holds the canonical path and removes the folder with everything in it when it is dropped, also when the test panics. When the removal fails, the guard gives the owner read, write and search permission on every folder in the tree (Unix; links are not followed) and removes it again, so a test that panics while a folder is locked leaves nothing behind. A removal that still fails is ignored. tree() empties the folder temp_dir returns in place instead of removing and remaking it, so on Unix the folder keeps the owner-only mode that temp_dir sets. --- crates/engine/src/cmd/findfiles/tests.rs | 109 +++++++++++++++++++++-- 1 file changed, 103 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/findfiles/tests.rs b/crates/engine/src/cmd/findfiles/tests.rs index bf51ebdac7..9340e5a8c6 100644 --- a/crates/engine/src/cmd/findfiles/tests.rs +++ b/crates/engine/src/cmd/findfiles/tests.rs @@ -43,12 +43,62 @@ impl Visitor for Blocked { } } +/// A test's folder tree, removed with everything in it when dropped, also when the test panics, +/// and also when the test locked a folder in it. A removal that fails (a walker still holding a +/// file open) is ignored. +struct Tree(PathBuf); + +impl std::ops::Deref for Tree { + type Target = PathBuf; + fn deref(&self) -> &PathBuf { + &self.0 + } +} + +impl Drop for Tree { + fn drop(&mut self) { + if std::fs::remove_dir_all(&self.0).is_err() { + unlock(&self.0); + let _ = std::fs::remove_dir_all(&self.0); + } + } +} + +/// Gives the owner read, write and search permission on `folder` and on every folder in it, so +/// that a tree holding a folder a test locked can be removed. Links are not followed. +#[cfg(unix)] +fn unlock(folder: &Path) { + use std::os::unix::fs::PermissionsExt; + let Ok(meta) = std::fs::symlink_metadata(folder) else { return }; + if !meta.is_dir() { + return; + } + let mode = meta.permissions().mode() & 0o7777; + if mode & 0o700 != 0o700 { + let _ = std::fs::set_permissions(folder, std::fs::Permissions::from_mode(mode | 0o700)); + } + let Ok(entries) = std::fs::read_dir(folder) else { return }; + for entry in entries.flatten() { + if entry.file_type().is_ok_and(|t| t.is_dir()) { + unlock(&entry.path()); + } + } +} + +/// Folders on other systems are removed as they are. +#[cfg(not(unix))] +fn unlock(_: &Path) {} + /// A fresh folder (canonical, as a search reports it) holding `paths`: files, and folders where -/// they end in `/`. -fn tree(tag: &str, paths: &[&str]) -> PathBuf { - let root = vectorcraft_testkit::temp_dir(&format!("findfiles-{tag}")); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(&root).unwrap(); +/// they end in `/`. The folder is removed when the returned [`Tree`] is dropped. +fn tree(tag: &str, paths: &[&str]) -> Tree { + let mut root = Tree(vectorcraft_testkit::temp_dir(&format!("findfiles-{tag}"))); + // Empties what a tree whose removal failed left, keeping the folder as `temp_dir` made it + // (owner-only on Unix). + for entry in std::fs::read_dir(&root.0).unwrap().flatten() { + let path = entry.path(); + let _ = if entry.file_type().is_ok_and(|t| t.is_dir()) { std::fs::remove_dir_all(&path) } else { std::fs::remove_file(&path) }; + } for p in paths { let path = root.join(p); if p.ends_with('/') { @@ -58,7 +108,54 @@ fn tree(tag: &str, paths: &[&str]) -> PathBuf { std::fs::write(&path, b"font").unwrap(); } } - plain(std::fs::canonicalize(&root).unwrap()) + root.0 = plain(std::fs::canonicalize(&root.0).unwrap()); + root +} + +#[test] +fn a_tree_is_removed_when_its_test_ends() { + let path = { + let root = tree("removed", &["a/b/one.ttf", "c/"]); + assert!(root.join("a/b/one.ttf").is_file() && root.join("c").is_dir()); + root.to_path_buf() + }; + assert!(!path.exists(), "{} is left", path.display()); +} + +#[test] +fn a_tree_is_removed_when_its_test_panics() { + let mut path = PathBuf::new(); + let r = vectorcraft_testkit::catch_quiet::<()>(|| { + let root = tree("unwound", &["a/b/one.ttf"]); + path = root.to_path_buf(); + panic!("a failed assertion"); + }); + assert_eq!(r.err().as_deref(), Some("a failed assertion")); + assert!(!path.as_os_str().is_empty() && !path.exists(), "{} is left", path.display()); +} + +#[cfg(unix)] +#[test] +fn a_tree_is_private_to_this_user() { + use std::os::unix::fs::PermissionsExt; + let root = tree("private", &["a/one.ttf"]); + let mode = std::fs::metadata(&*root).unwrap().permissions().mode(); + assert_eq!(mode & 0o077, 0, "{}: mode {mode:o}", root.display()); +} + +#[cfg(unix)] +#[test] +fn a_tree_with_a_locked_folder_is_removed_when_its_test_panics() { + use std::os::unix::fs::PermissionsExt; + let mut path = PathBuf::new(); + let r = vectorcraft_testkit::catch_quiet::<()>(|| { + let root = tree("locked", &["a/locked/b/one.ttf"]); + path = root.to_path_buf(); + std::fs::set_permissions(root.join("a/locked"), std::fs::Permissions::from_mode(0o000)).unwrap(); + panic!("a failed assertion"); + }); + assert_eq!(r.err().as_deref(), Some("a failed assertion")); + assert!(!path.as_os_str().is_empty() && !path.exists(), "{} is left", path.display()); } /// The rules of a search of `root`: its `Users/me` is the home folder, `refused` an app's folder. From f207ac35183874b5a8d237fc5dff52ee20c1fddb Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 10:39:17 -0800 Subject: [PATCH 74/92] Type: a Tabs indent drag over many paragraphs no longer takes time in the square of their number When a Tabs panel indent marker gave the paragraphs of a threaded story values of their own, the panel sent one text.setFormat for each paragraph, and the story flowed through its frames again after every one of them. text.setFormat now takes paragraphIndents, a list of {id, start, leftIndent?, firstLineIndent?} entries, one per paragraph. It checks every entry before it changes anything, rebuilds each type object's paragraph styles once and makes the whole change one edit, so the story flows again once. The markers send one such command inside the same labeled command.batch, and a drag that changes no paragraph runs nothing. - With the Selection tool, a marker drag on any frame of a threaded story now sets every paragraph of the story, in its locked and hidden frames too. - The single-value form of text.setFormat fails, naming the key, when a number such as leftIndent is given as a string, a switch or a list. - A null value of any text.setFormat attribute leaves it as it is, as an omitted one does. A call whose attributes are all null fails with "nothing to change" and records no edit and no undo step. - A text.setFormat call whose values are the ones the text already has returns its ids and records no edit, no undo step and no new revision. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/panelcmds.rs | 194 ++++++++++++++++++-- crates/engine/src/cmd/threads/tests.rs | 161 ++++++++++++++++ crates/engine/src/cmd/typecmd/area_tests.rs | 124 +++++++++++++ crates/engine/src/cmd/variablecmds.rs | 4 +- crates/ui-egui/src/panels/tabs.rs | 117 +++++++----- crates/ui-egui/src/panels/tabs/tests.rs | 125 ++++++++++--- docs/mcp.md | 22 +++ docs/target-app-parity.md | 2 +- 8 files changed, 656 insertions(+), 93 deletions(-) diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 83a0655284..f94494625b 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec<CommandSpec> { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment; a number or switch given as another type fails the call, and a null value leaves its attribute as it is, so a call with only null values fails with nothing to change; a call that gives the values the text already has records no undo step). Or {paragraphIndents: [{id, start: byte where the paragraph starts, leftIndent?, firstLineIndent?: pt -1296..1296}…]} alone (Tabs panel indent markers over paragraphs with indents of their own): each entry sets one paragraph of a type object, at least one indent each, at most 100000 entries; every entry is checked before anything changes, and an unknown key, a value out of range, an object that is not type, a byte where no paragraph starts or two entries for one paragraph with other values fail the call; all of it is one edit, a threaded story flowing again once, and when no paragraph's indents change nothing is recorded → {ids}", has_doc, set_format ), @@ -467,6 +467,9 @@ fn list_level_param(p: &Value, cmd: &str) -> Result<Option<u8>> { fn set_format(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.setFormat"; + if p.get("paragraphIndents").is_some() { + return set_paragraph_indents(s, p); + } let ids = super::typecmd::text_targets(s, p, C)?; check_switches( p, @@ -486,6 +489,27 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { ], C, )?; + // Each value that `num` reads below must be a number or null. + super::check_numbers( + p, + &[ + "baselineShift", + "hScale", + "vScale", + "rotation", + "leftIndent", + "rightIndent", + "firstLineIndent", + "spaceBefore", + "spaceAfter", + "wordsLongerThan", + "afterFirst", + "beforeLast", + "hyphenLimit", + "hyphenationZone", + ], + C, + )?; let num = |k: &str| p.get(k).and_then(Value::as_f64); let flag = |k: &str| p.get(k).and_then(Value::as_bool); let kerning = match p.get("kerning") { @@ -557,7 +581,8 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { "kashida", "composer", ]; - if !keys.iter().any(|k| p.get(*k).is_some()) { + // A null value leaves its attribute as it is, as an omitted one does. + if !keys.iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) { return Err(bad(C, "nothing to change")); } // The anti-aliasing method belongs to the whole type object; each frame keeps its own method. @@ -587,7 +612,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { let tcy_up_down = super::textedit::tcy_offset_param(p, "tcyUpDown", C)?; let tcy_left_right = super::textedit::tcy_offset_param(p, "tcyLeftRight", C)?; let mojikumi = match p.get("mojikumi") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("none") => vectorcraft_doc::Mojikumi::None, Some("lineEndHalf") => vectorcraft_doc::Mojikumi::LineEndHalf, @@ -595,7 +620,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { }), }; let kinsoku = match p.get("kinsoku") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("none") => vectorcraft_doc::Kinsoku::None, Some("hard") => vectorcraft_doc::Kinsoku::Hard, @@ -604,7 +629,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { }), }; let kinsoku_type = match p.get("kinsokuType") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("pushInFirst") => vectorcraft_doc::KinsokuShoriType::PushInFirst, Some("pushOutFirst") => vectorcraft_doc::KinsokuShoriType::PushOutFirst, @@ -613,7 +638,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { }), }; let direction = match p.get("direction") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("auto") => None, Some("leftToRight") => Some(vectorcraft_doc::ParaDirection::LeftToRight), @@ -622,7 +647,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { }), }; let leading_model = match p.get("leadingModel") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("romanBaseline") => vectorcraft_doc::LeadingModel::RomanBaseline, Some("emBoxTop") => vectorcraft_doc::LeadingModel::EmBoxTop, @@ -630,7 +655,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { }), }; let burasagari = match p.get("burasagari") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("none") => vectorcraft_doc::Burasagari::None, Some("standard") => vectorcraft_doc::Burasagari::Standard, @@ -639,7 +664,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { }), }; let kashida = match p.get("kashida") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("none") => vectorcraft_doc::text::Kashida::None, Some("short") => vectorcraft_doc::text::Kashida::Short, @@ -670,7 +695,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { let spacing = |k: &str| spacing_param(p, k, C); let (word_spacing, letter_spacing, glyph_scaling) = (spacing("wordSpacing")?, spacing("letterSpacing")?, spacing("glyphScaling")?); let single_word = match p.get("singleWordJustification") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("fullJustify") => SingleWordJustify::FullJustify, Some("alignLeft") => SingleWordJustify::AlignLeft, @@ -680,7 +705,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { }), }; let auto_leading = match p.get("autoLeading") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(C, "`autoLeading` must be a number"))?), }; // A paragraph whose Minimum, Desired and Maximum would no longer be in order. @@ -787,12 +812,20 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { l.level = list_level.unwrap_or(l.level); } }; - s.edit("Character", |d, _| { + // The change is made on a copy first: a call whose values are the ones the text already has + // records no edit, undo step or revision. Only the type objects and graph label styles the call + // changes are compared, not the whole document. + super::variablecmds::settle_typing(s)?; + let mut doc = (*s.doc()?.doc).clone(); + let mut changed = false; + { + let d = &mut doc; // The paragraph change, for the graph labels to keep. let mut para_change = None; let out_of_order_error = |name: &str| bad(C, format!("`{name}`: Minimum must not exceed Desired, nor Desired Maximum")); for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + let before = t.clone(); let baseline_shift = num("baselineShift").map(|v| super::typecmd::local_type_value(t, v, (-1296.0, 1296.0), false, C)).transpose()?; let h_scale = num("hScale").map(|v| super::typecmd::local_type_value(t, v, (1.0, 10000.0), true, C)).transpose()?; let local = |v: Option<f64>| v.map(|v| super::typecmd::local_type_value(t, v, (-1296.0, 1296.0), false, C)).transpose(); @@ -896,16 +929,17 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { return Err(out_of_order_error(name)); } super::typecmd::refresh_bounds(t); + changed |= *t != before; para_change = Some(paras); } // Graph labels keep a change made to all of their text through the graph being drawn again. if range.is_none() { let shift = num("baselineShift").map(|v| v.clamp(-1296.0, 1296.0)); let scale = num("hScale").map(|v| v.clamp(1.0, 10000.0)); - super::graph::capture_label_styles(d, &ids, &|st| chars(st, shift, scale, (tcy_up_down, tcy_left_right))); + changed |= super::graph::capture_label_styles(d, &ids, &|st| chars(st, shift, scale, (tcy_up_down, tcy_left_right))); if let Some(paras) = para_change { // The kept attributes are checked too: they may differ from the labels'. - super::graph::capture_label_paragraphs(d, &ids, &|para| { + changed |= super::graph::capture_label_paragraphs(d, &ids, &|para| { let mut out_of_order = None; paras(para, &mut out_of_order); out_of_order.map_or(Ok(()), |name| Err(out_of_order_error(name))) @@ -915,14 +949,20 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { if let Some(v) = anti_alias { for id in &ids { if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { + changed |= t.anti_alias != v; t.anti_alias = v; } } // Graph labels keep the method through the graph being drawn again. - super::graph::capture_label_anti_alias(d, &ids, v); + changed |= super::graph::capture_label_anti_alias(d, &ids, v); } - Ok(()) - })?; + } + if changed { + s.edit("Character", |d, _| { + *d = doc; + Ok(()) + })?; + } let mut result = json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }); if let Some(kerning) = p.get("kerning") { result["kerning"] = kerning.clone(); @@ -930,6 +970,126 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { Ok(result) } +/// The most entries `paragraphIndents` takes in one `text.setFormat` call. +pub(crate) const MAX_PARAGRAPH_INDENTS: usize = 100_000; + +/// The indents one `paragraphIndents` entry gives its paragraph. +#[derive(Clone, Copy, Debug, PartialEq)] +struct ParagraphIndent { + left: Option<f64>, + first_line: Option<f64>, +} + +/// A type object that `paragraphIndents` names: the bytes where its paragraphs start and the +/// indents its entries give them, by paragraph index. +struct IndentedFrame { + id: NodeId, + starts: Vec<usize>, + changes: Vec<(usize, ParagraphIndent)>, +} + +/// `text.setFormat` with `paragraphIndents`: each entry names one paragraph of a type object, by +/// the object and the byte where the paragraph starts, and sets its left and first-line indents. +/// Every entry is checked against the document before anything changes. Each type object's +/// paragraph styles are rebuilt once and the whole change is one edit, so a threaded story flows +/// again once however many of its paragraphs change. When no entry changes its paragraph, nothing +/// is edited. +fn set_paragraph_indents(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "text.setFormat"; + const KEY: &str = "paragraphIndents"; + if let Some(other) = p.as_object().and_then(|o| o.keys().find(|k| *k != KEY)) { + return Err(bad(C, format!("`{KEY}` takes no `{other}` beside it"))); + } + let entries = p.get(KEY).and_then(Value::as_array).ok_or_else(|| bad(C, format!("`{KEY}` must be a list")))?; + if entries.is_empty() { + return Err(bad(C, format!("`{KEY}` is empty"))); + } + if entries.len() > MAX_PARAGRAPH_INDENTS { + return Err(bad(C, format!("`{KEY}` takes at most {MAX_PARAGRAPH_INDENTS} entries (got {})", entries.len()))); + } + let d = &s.doc()?.doc; + // The type objects in the order they first appear. + let mut frames: Vec<IndentedFrame> = vec![]; + let mut frame_at: std::collections::HashMap<NodeId, usize> = std::collections::HashMap::new(); + let mut given: std::collections::HashMap<(NodeId, usize), ParagraphIndent> = std::collections::HashMap::new(); + for (n, e) in entries.iter().enumerate() { + let e = e.as_object().ok_or_else(|| bad(C, format!("`{KEY}` entry {n} must be an object")))?; + if let Some(k) = e.keys().find(|k| !["id", "start", "leftIndent", "firstLineIndent"].contains(&k.as_str())) { + return Err(bad(C, format!("`{KEY}` entry {n}: unknown key `{k}`"))); + } + let indent = |k: &str| -> Result<Option<f64>> { + match e.get(k) { + None => Ok(None), + Some(v) => match v.as_f64() { + Some(x) if x.is_finite() && x.abs() <= MAX_PARA => Ok(Some(x)), + _ => Err(bad(C, format!("`{KEY}` entry {n}: `{k}` must be a number from -{MAX_PARA} to {MAX_PARA} pt"))), + }, + } + }; + let change = ParagraphIndent { left: indent("leftIndent")?, first_line: indent("firstLineIndent")? }; + if change.left.is_none() && change.first_line.is_none() { + return Err(bad(C, format!("`{KEY}` entry {n} sets neither `leftIndent` nor `firstLineIndent`"))); + } + let id = NodeId(e.get("id").and_then(Value::as_u64).ok_or_else(|| bad(C, format!("`{KEY}` entry {n}: `id` must be an object id")))?); + let start = index(e.get("start").and_then(Value::as_u64).ok_or_else(|| bad(C, format!("`{KEY}` entry {n}: `start` must be a byte offset")))?); + let k = match frame_at.get(&id) { + Some(k) => *k, + None => { + let Some(NodeKind::Text(t)) = d.node(id).map(|n| &n.kind) else { + return Err(bad(C, format!("`{KEY}` entry {n}: {} is not a type object", id.0))); + }; + let plain = t.plain_text(); + let mut starts = vec![0]; + starts.extend(plain.bytes().enumerate().filter_map(|(i, c)| (c == b'\n').then_some(i + 1))); + frames.push(IndentedFrame { id, starts, changes: vec![] }); + frame_at.insert(id, frames.len() - 1); + frames.len() - 1 + } + }; + let Some(IndentedFrame { starts, changes, .. }) = frames.get_mut(k) else { continue }; + let para = starts.binary_search(&start).map_err(|_| bad(C, format!("`{KEY}` entry {n}: no paragraph of {} starts at byte {start}", id.0)))?; + match given.insert((id, para), change) { + None => changes.push((para, change)), + Some(was) if was == change => {} + Some(_) => { + return Err(bad( + C, + format!("`{KEY}` entry {n} gives the paragraph at byte {start} of {} other indents than an entry before it", id.0), + )); + } + } + } + let ids = json!({ "ids": frames.iter().map(|f| f.id.0).collect::<Vec<_>>() }); + // Only the type objects with a paragraph whose indents change are edited. + let differs = |t: &vectorcraft_doc::TextObject, i: usize, c: &ParagraphIndent| { + let para = t.para_at(i); + c.left.is_some_and(|v| v != para.left_indent) || c.first_line.is_some_and(|v| v != para.first_line_indent) + }; + frames.retain(|f| match d.node(f.id).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => f.changes.iter().any(|(i, c)| differs(t, *i, c)), + _ => false, + }); + if frames.is_empty() { + return Ok(ids); + } + s.edit("Character", |d, _| { + for f in &frames { + let Some(NodeKind::Text(t)) = d.node_mut(f.id).map(|n| &mut n.kind) else { continue }; + let mut styles = t.paragraph_styles(); + for (i, c) in &f.changes { + if let Some(para) = styles.get_mut(*i) { + para.left_indent = c.left.unwrap_or(para.left_indent); + para.first_line_indent = c.first_line.unwrap_or(para.first_line_indent); + } + } + t.set_paragraph_styles(styles); + super::typecmd::refresh_bounds(t); + } + Ok(()) + })?; + Ok(ids) +} + /// The part of a story paragraph in one frame, as [`convert_list_to_text`] reads it: the frame, /// the frame's paragraph, its bytes there, its list, the marker the frame's layout draws for it /// and the numbered paragraphs the frame counts before it at each list level. diff --git a/crates/engine/src/cmd/threads/tests.rs b/crates/engine/src/cmd/threads/tests.rs index 48a1e46fbc..b6ea41df3e 100644 --- a/crates/engine/src/cmd/threads/tests.rs +++ b/crates/engine/src/cmd/threads/tests.rs @@ -1679,3 +1679,164 @@ fn the_frame_a_release_leaves_alone_has_the_bounds_of_the_whole_story() { let cached = t.cached_bounds.unwrap(); assert!((cached.y0 - bounds.y0).abs() < 1e-6 && (cached.y1 - bounds.y1).abs() < 1e-6, "{cached:?} {bounds:?}"); } + +/// Twelve paragraphs that wrap, threaded through three frames, the first two of which end inside +/// a paragraph; the last frame's paragraphs are indented 10 pt. → the session and the frames. +fn split_story() -> (Session, [u64; 3]) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 900, "height": 1000})).unwrap(); + let words = "aaa bbb ccc ddd eee fff ggg hhh iii jjj ".repeat(3); + let story = (1..=12).map(|i| format!("p{i} {}", words.trim_end())).collect::<Vec<_>>().join("\n"); + let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 50}})).unwrap()["id"] + .as_u64() + .unwrap(); + let b = s.execute("text.thread.add", &json!({"from": a, "rect": [300, 10, 150, 50]})).unwrap()["id"].as_u64().unwrap(); + let c = s.execute("text.thread.add", &json!({"from": b, "rect": [600, 10, 250, 950]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.setFormat", &json!({"ids": [c], "leftIndent": 10})).unwrap(); + for id in [b, c] { + assert!(text_of(&s.doc().unwrap().doc, NodeId(id)).unwrap().first_para_continues, "frame {id} starts inside a paragraph"); + } + (s, [a, b, c]) +} + +/// Each frame's text, paragraph styles and whether it continues a paragraph. +fn frames_of(s: &Session, frames: &[u64]) -> Vec<(String, Vec<vectorcraft_doc::ParaStyle>, bool)> { + frames + .iter() + .map(|id| { + let t = text_of(&s.doc().unwrap().doc, NodeId(*id)).unwrap(); + (t.plain_text(), t.paragraph_styles(), t.first_para_continues) + }) + .collect() +} + +/// One entry for each paragraph of the story: a paragraph split between frames by its piece in the +/// later frame. Each takes indents of its own, wide enough to move text between the frames. +fn indent_entries(s: &Session, frames: &[u64]) -> Vec<Value> { + let mut out = vec![]; + for (k, id) in frames.iter().enumerate() { + let t = text_of(&s.doc().unwrap().doc, NodeId(*id)).unwrap(); + let next_continues = frames.get(k + 1).is_some_and(|n| text_of(&s.doc().unwrap().doc, NodeId(*n)).unwrap().first_para_continues); + let plain = t.plain_text(); + let pieces: Vec<&str> = plain.split('\n').collect(); + let mut start = 0; + for (i, piece) in pieces.iter().enumerate() { + if i + 1 < pieces.len() || !next_continues { + let n = out.len() as f64; + out.push(json!({"id": id, "start": start, "leftIndent": 20.0 + n, "firstLineIndent": 15.0 - n})); + } + start += piece.len() + 1; + } + } + out +} + +/// `paragraphIndents` sets each paragraph of a threaded story as one `text.setFormat` command for +/// each paragraph would, the story's last paragraph first, with a paragraph split between frames +/// set once; the story flows again once, and Undo and Redo take the whole change as one step. +#[test] +fn paragraph_indents_flow_a_threaded_story_once_as_one_command_per_paragraph_would() { + let (mut one, frames) = split_story(); + let (mut each, _) = split_story(); + let entries = indent_entries(&one, &frames); + assert_eq!(entries.len(), 12, "one entry for each paragraph of the story: {entries:?}"); + let commands: Vec<Value> = entries + .iter() + .rev() + .map(|e| { + json!({"command": "text.setFormat", "params": { + "ids": [e["id"]], "start": e["start"], "end": e["start"], "leftIndent": e["leftIndent"], "firstLineIndent": e["firstLineIndent"] + }}) + }) + .collect(); + each.execute("command.batch", &json!({"label": "Left Indent", "commands": commands})).unwrap(); + let start = frames_of(&one, &frames); + let undo = one.doc().unwrap().history.undo.len(); + let copied = COPIED.with(std::cell::Cell::get); + let r = one.execute("text.setFormat", &json!({"paragraphIndents": entries})).unwrap(); + assert_eq!(COPIED.with(std::cell::Cell::get), copied + 1, "the story flows again once"); + // The first frame holds only the start of the paragraph that runs on into the second. + assert_eq!(r, json!({"ids": frames[1..]})); + assert_eq!(one.doc().unwrap().history.undo.len(), undo + 1); + let set = frames_of(&one, &frames); + assert_ne!(set, start); + assert_eq!(set, frames_of(&each, &frames)); + one.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(frames_of(&one, &frames), start); + one.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(frames_of(&one, &frames), set); +} + +/// Thousands of paragraphs of a threaded story are set by one edit, which flows the story again +/// once. +#[test] +fn paragraph_indents_over_thousands_of_paragraphs_flow_the_story_once() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 900, "height": 1000})).unwrap(); + let story = vec!["x"; 5000].join("\n"); + let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 300}})).unwrap()["id"] + .as_u64() + .unwrap(); + let b = s.execute("text.thread.add", &json!({"from": a, "rect": [300, 10, 150, 300]})).unwrap()["id"].as_u64().unwrap(); + let entries: Vec<Value> = [a, b] + .into_iter() + .flat_map(|id| { + let t = text_of(&s.doc().unwrap().doc, NodeId(id)).unwrap(); + let count = t.paragraph_count(); + (0..count).map(move |i| json!({"id": id, "start": 2 * i, "leftIndent": (i % 7) as f64})) + }) + .collect(); + assert!(entries.len() >= 5000, "{}", entries.len()); + let (undo, copied) = (s.doc().unwrap().history.undo.len(), COPIED.with(std::cell::Cell::get)); + s.execute("text.setFormat", &json!({"paragraphIndents": entries})).unwrap(); + assert_eq!((s.doc().unwrap().history.undo.len(), COPIED.with(std::cell::Cell::get)), (undo + 1, copied + 1)); + let t = text_of(&s.doc().unwrap().doc, NodeId(a)).unwrap(); + assert_eq!((t.para_at(0).left_indent, t.para_at(1).left_indent, t.para_at(8).left_indent), (0.0, 1.0, 1.0)); +} + +/// `paragraphIndents` that would change no paragraph leaves the document and its history alone, and +/// a bad one is refused before anything changes. +#[test] +fn paragraph_indents_check_every_entry_and_skip_a_change_that_changes_nothing() { + let (mut s, [a, b, c]) = split_story(); + let start = frames_of(&s, &[a, b, c]); + let undo = s.doc().unwrap().history.undo.len(); + let copied = COPIED.with(std::cell::Cell::get); + let r = + s.execute("text.setFormat", &json!({"paragraphIndents": [{"id": a, "start": 0, "leftIndent": 0}, {"id": c, "start": 0, "leftIndent": 10}]})); + assert_eq!(r.unwrap(), json!({"ids": [a, c]})); + assert_eq!((s.doc().unwrap().history.undo.len(), COPIED.with(std::cell::Cell::get)), (undo, copied), "nothing to change"); + let paragraph = text(&s, c).find('\n').unwrap() + 1; + let rect = s.execute("shape.rectangle", &json!({"x": 10, "y": 900, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let ok = json!({"id": c, "start": paragraph, "leftIndent": 30}); + for bad in [ + json!({"paragraphIndents": []}), + json!({"paragraphIndents": {"id": a, "start": 0, "leftIndent": 30}}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 30}], "ids": [a]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 30}], "leftIndent": 5}), + json!({"paragraphIndents": [ok, 3]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0}]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": "30"}]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 1300}]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0, "firstLineIndent": -1297}]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 30, "spaceBefore": 4}]}), + json!({"paragraphIndents": [ok, {"id": "a", "start": 0, "leftIndent": 30}]}), + json!({"paragraphIndents": [ok, {"id": rect, "start": 0, "leftIndent": 30}]}), + json!({"paragraphIndents": [ok, {"id": 999_999, "start": 0, "leftIndent": 30}]}), + json!({"paragraphIndents": [ok, {"id": a, "leftIndent": 30}]}), + json!({"paragraphIndents": [ok, {"id": a, "start": -1, "leftIndent": 30}]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 1, "leftIndent": 30}]}), + json!({"paragraphIndents": [ok, {"id": c, "start": paragraph, "leftIndent": 31}]}), + json!({"paragraphIndents": [ok, {"id": c, "start": paragraph, "leftIndent": 30, "firstLineIndent": 0}]}), + json!({"paragraphIndents": vec![ok.clone(); crate::cmd::panelcmds::MAX_PARAGRAPH_INDENTS + 1]}), + ] { + assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{bad}"); + assert_eq!(frames_of(&s, &[a, b, c]), start, "{bad}"); + } + // The same entry twice is one change. + s.execute("text.setFormat", &json!({"paragraphIndents": [ok, ok]})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(text_of(&s.doc().unwrap().doc, NodeId(c)).unwrap().para_at(1).left_indent, 30.0); +} diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 5545e1be94..d12bd8acae 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -2156,3 +2156,127 @@ fn set_path_handle_reshapes_type_on_a_path() { assert!(s.execute("text.setPathHandle", &json!({"id": id.0, "anchor": 9, "which": "out", "x": 0, "y": 0})).is_err(), "no such anchor"); assert!(s.execute("text.setPathHandle", &json!({"id": path["id"], "anchor": 0, "which": "out", "x": 0, "y": 0})).is_err(), "not type"); } + +/// `text.setFormat` refuses a number or switch given as another type, naming the key, and +/// changes nothing; null leaves the attribute as it is. +#[test] +fn set_format_refuses_a_param_of_the_wrong_type() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let state = |s: &Session| (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let before = state(&s); + for key in [ + "baselineShift", + "hScale", + "vScale", + "rotation", + "leftIndent", + "rightIndent", + "firstLineIndent", + "spaceBefore", + "spaceAfter", + "wordsLongerThan", + "afterFirst", + "beforeLast", + "hyphenLimit", + "hyphenationZone", + ] { + for wrong in [json!("30"), json!(true), json!([30])] { + let err = s.execute("text.setFormat", &json!({ key: wrong })).unwrap_err().to_string(); + assert!(err.contains(&format!("`{key}` must be a number, not {wrong}")), "{key}: {err}"); + } + } + for key in ["underline", "allCaps", "hyphenate", "noBreak"] { + let err = s.execute("text.setFormat", &json!({ key: "true" })).unwrap_err().to_string(); + assert!(err.contains(&format!("`{key}` must be true or false")), "{key}: {err}"); + } + let err = s.execute("text.setFormat", &json!({"leftIndent": 30, "firstLineIndent": "12"})).unwrap_err().to_string(); + assert!(err.contains("`firstLineIndent` must be a number"), "{err}"); + assert!(state(&s) == before, "a refused call changes nothing and records no undo step"); + s.execute("text.setFormat", &json!({"leftIndent": 30, "firstLineIndent": null})).unwrap(); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert_eq!((t.para_at(0).left_indent, t.para_at(0).first_line_indent), (30.0, 0.0)); +} + +/// `text.setFormat` reads a null value as one left out: a call whose attributes are all null has +/// nothing to change, and records no edit, no undo step and no new revision. +#[test] +fn set_format_with_only_null_values_changes_nothing() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let state = |s: &Session| { + let st = s.doc().unwrap(); + (st.doc.clone(), st.history.undo.len(), st.revision) + }; + let before = state(&s); + for params in [ + json!({"leftIndent": null}), + json!({"leftIndent": null, "firstLineIndent": null, "underline": null}), + json!({"kerning": null, "antiAlias": null, "list": null, "wordSpacing": null}), + json!({"mojikumi": null}), + json!({"kinsoku": null, "kinsokuType": null, "direction": null, "leadingModel": null}), + json!({"burasagari": null, "kashida": null, "singleWordJustification": null, "autoLeading": null}), + json!({"ids": [id], "start": 0, "end": 3, "spaceBefore": null}), + ] { + let err = s.execute("text.setFormat", ¶ms).unwrap_err().to_string(); + assert!(err.contains("nothing to change"), "{params}: {err}"); + assert!(state(&s) == before, "{params}: no edit, no undo step and no new revision"); + } + // A null beside a value leaves only its own attribute. + let para = |d: &vectorcraft_doc::Document| { + let NodeKind::Text(t) = &d.node(NodeId(id)).unwrap().kind else { panic!("text") }; + (t.para_at(0).mojikumi, t.para_at(0).kinsoku) + }; + let mojikumi = para(&before.0).0; + s.execute("text.setFormat", &json!({"mojikumi": null, "kinsoku": "soft"})).unwrap(); + assert_eq!(para(&s.doc().unwrap().doc), (mojikumi, vectorcraft_doc::Kinsoku::Soft)); + assert_eq!(s.doc().unwrap().history.undo.len(), before.1 + 1, "one undo step"); +} + +/// `text.setFormat` with the values the text already has succeeds and names its objects, but +/// records no edit, no undo step and no new revision. +#[test] +fn set_format_with_the_current_values_records_nothing() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let state = |s: &Session| { + let st = s.doc().unwrap(); + (st.doc.clone(), st.history.undo.len(), st.revision) + }; + let before = state(&s); + let NodeKind::Text(t) = &before.0.node(NodeId(id)).unwrap().kind else { panic!("text") }; + let hyphenate = t.para_at(0).hyphenate; + for params in [ + json!({"leftIndent": 0, "firstLineIndent": 0}), + json!({"underline": false, "strikethrough": false, "hyphenate": hyphenate}), + json!({"ids": [id], "start": 0, "end": 3, "spaceBefore": 0, "spaceAfter": 0}), + json!({"ids": [id], "start": 4, "end": 7, "leftIndent": null, "rightIndent": 0}), + ] { + let out = s.execute("text.setFormat", ¶ms).unwrap(); + assert_eq!(out["ids"], json!([id]), "{params}"); + assert!(state(&s) == before, "{params}: no edit, no undo step and no new revision"); + } + // A call that changes a value is one undo step; the same call again records nothing. + s.execute("text.setFormat", &json!({"leftIndent": 24})).unwrap(); + let changed = state(&s); + assert_eq!((changed.1, changed.2), (before.1 + 1, before.2 + 1), "one undo step and one revision"); + s.execute("text.setFormat", &json!({"leftIndent": 24})).unwrap(); + assert!(state(&s) == changed, "the same value again records nothing"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc == before.0, "one undo restores the text"); + // The anti-aliasing method is compared the same way. + s.execute("text.setFormat", &json!({"antiAlias": "none"})).unwrap(); + s.execute("text.setFormat", &json!({"antiAlias": "strong"})).unwrap(); + let strong = state(&s); + assert!(strong.1 > before.1, "a new method is an undo step"); + let NodeKind::Text(t) = &strong.0.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert_eq!(t.anti_alias, vectorcraft_doc::text::TypeAntiAlias::Strong); + s.execute("text.setFormat", &json!({"antiAlias": "strong"})).unwrap(); + assert!(state(&s) == strong, "the same method again records nothing"); +} diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index ad70f047bc..dc393003ac 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -46,9 +46,9 @@ fn selection_and_unlocked(s: &Session) -> std::result::Result<(), String> { variables_unlocked(s) } -/// Ends a typing session as [`Session::edit`] would, before a helper below compares the document, +/// Ends a typing session as [`Session::edit`] would, before a command compares the document, /// so the typing is its own undo step whether or not the command then changes anything. -fn settle_typing(s: &mut Session) -> Result<()> { +pub(super) fn settle_typing(s: &mut Session) -> Result<()> { if !s.previewing && super::typing_in_progress(s) { super::settle_interaction(s)?; } diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index c7b8e47828..77dd07e15f 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -84,11 +84,10 @@ fn append_indent_targets(node: &Node, range: std::ops::Range<usize>, targets: &m /// one frame ends and the next begins, the last paragraph of the one and the first of the next are /// pieces of one story paragraph: a paragraph that runs on into the next frame, or an empty piece /// after a frame's last paragraph break. Such a paragraph is targeted once, by its last piece with -/// text in a frame that `selected` takes, the piece whose style the story keeps when it flows again. -/// A paragraph whose text lies only in frames that `selected` leaves out is not targeted. An empty -/// paragraph takes its style from its last piece when the story flows again, so it is targeted by -/// that piece when `selected` takes its frame, and otherwise not. -fn append_story_targets(doc: &Document, story: &[NodeId], selected: impl Fn(NodeId) -> bool, targets: &mut Vec<IndentTarget>) { +/// text, the piece whose style the story keeps when it flows again. An empty paragraph takes its +/// style from its last piece when the story flows again, so it is targeted by that piece. Locked and +/// hidden frames are targeted like the others. +fn append_story_targets(doc: &Document, story: &[NodeId], targets: &mut Vec<IndentTarget>) { /// The part of a story paragraph in one frame: paragraph `index` there, starting at byte `start`. struct Piece<'a> { id: NodeId, @@ -113,11 +112,7 @@ fn append_story_targets(doc: &Document, story: &[NodeId], selected: impl Fn(Node } } for pieces in ¶graphs { - let pick = if pieces.iter().any(|p| p.has_text) { - pieces.iter().rev().find(|p| p.has_text && selected(p.id)) - } else { - pieces.last().filter(|p| selected(p.id)) - }; + let pick = pieces.iter().rev().find(|p| p.has_text).or_else(|| pieces.last()); if let Some(p) = pick { targets.push(indent_target(p.id, p.text, p.index, p.start)); } @@ -125,13 +120,14 @@ fn append_story_targets(doc: &Document, story: &[NodeId], selected: impl Fn(Node } /// Paragraphs touched by the Type selection (every paragraph of the story after Select All in -/// threaded text), or all paragraphs of selected text objects. Each paragraph of a threaded story -/// is listed once, in story order ([`append_story_targets`]). +/// threaded text), or all paragraphs of selected text objects. A selected frame of a threaded story +/// selects the whole story, its locked and hidden frames included. Each paragraph of a threaded +/// story is listed once, in story order ([`append_story_targets`]). fn indent_targets(app: &VectorcraftApp, node: &Node) -> Vec<IndentTarget> { let mut targets = Vec::new(); if let Some((_, a, b)) = super::character::text_editing(app) { if let (Some(story), Some(st)) = (super::character::text_story(app), app.session.active()) { - append_story_targets(&st.doc, &story, |_| true, &mut targets); + append_story_targets(&st.doc, &story, &mut targets); } else if let NodeKind::Text(text) = &node.kind { append_indent_targets(node, text.paragraphs_in(a, b), &mut targets); } @@ -153,7 +149,7 @@ fn indent_targets(app: &VectorcraftApp, node: &Node) -> Vec<IndentTarget> { } let story = st.doc.story_of(id); done.extend(story.iter().copied()); - append_story_targets(&st.doc, &story, |f| taken.contains(&f), &mut targets); + append_story_targets(&st.doc, &story, &mut targets); } } targets @@ -243,9 +239,9 @@ fn paint_markers(ui: &mut Ui, painter: &egui::Painter, tokens: &Tokens, view: Ma } } -/// Apply an absolute ruler position through paragraph-format commands. Paragraphs that need values -/// of their own get one command each, the story's last paragraph first: threaded text re-flows -/// after each command, and a re-flow moves only text after the paragraph that changed. +/// Apply an absolute ruler position through paragraph-format commands. When the paragraphs need +/// values of their own, one command sets each paragraph's ([`set_each_indent`]), and threaded text +/// flows again once. fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: &ParaStyle, span: f64, targets: &[IndentTarget], both: bool) { if targets.is_empty() || !target.is_finite() { return; @@ -253,23 +249,10 @@ fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: let Some(target) = bounded_marker_position(marker, target, span, targets, both) else { return }; if marker == IndentMarker::Upper { if targets.iter().all(|p| p.left == para.left_indent) { - if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", json!({"firstLineIndent": target - para.left_indent})) { - app.status(e); - } + run_on_targets(app, targets, json!({"firstLineIndent": target - para.left_indent})); } else { - super::character::end_typing(app); - let commands: Vec<Value> = targets - .iter() - .rev() - .map(|p| { - json!({"command": "text.setFormat", "params": { - "ids": [p.id], "start": p.start, "end": p.start, "firstLineIndent": target - p.left - }}) - }) - .collect(); - if let Err(e) = app.run("command.batch", json!({"label": tl!("First-line Left Indent"), "commands": commands})) { - app.status(e); - } + let changes = targets.iter().map(|p| (p, None, Some(target - p.left))).collect(); + set_each_indent(app, tl!("First-line Left Indent"), changes); } } else { // The ruler shows each paragraph's left indent with its list level's added; the paragraph @@ -284,26 +267,58 @@ fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: _ => None, }; if let Some(params) = params { - if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", params) { - app.status(e); - } + run_on_targets(app, targets, params); return; } - super::character::end_typing(app); - let commands: Vec<Value> = targets - .iter() - .rev() - .map(|p| { - let mut params = json!({"ids": [p.id], "start": p.start, "end": p.start, "leftIndent": target - p.level}); - if !both { - params["firstLineIndent"] = json!(p.left + p.first_line - target); - } - json!({"command": "text.setFormat", "params": params}) - }) - .collect(); - if let Err(e) = app.run("command.batch", json!({"label": tl!("Left Indent"), "commands": commands})) { - app.status(e); - } + let changes = targets.iter().map(|p| (p, Some(target - p.level), (!both).then_some(p.left + p.first_line - target))).collect(); + set_each_indent(app, tl!("Left Indent"), changes); + } +} + +/// Run one `text.setFormat` with `params` on the paragraphs the Paragraph panel acts on while typing, +/// and with the Selection tool on every type object of `targets`, so a selected frame of a threaded +/// story sets the whole story ([`indent_targets`]). +fn run_on_targets(app: &mut VectorcraftApp, targets: &[IndentTarget], mut params: Value) { + if super::character::text_editing(app).is_none() { + let mut seen = HashSet::new(); + params["ids"] = json!(targets.iter().map(|p| p.id).filter(|id| seen.insert(*id)).collect::<Vec<_>>()); + } + if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", params) { + app.status(e); + } +} + +/// Give each paragraph its own left and first-line indents (None: as it is) in one `text.setFormat` +/// with `paragraphIndents`, one undo step named `label`. Paragraphs that keep their indents are +/// left out, and when every paragraph keeps them nothing is run. +fn set_each_indent(app: &mut VectorcraftApp, label: &str, changes: Vec<(&IndentTarget, Option<f64>, Option<f64>)>) { + // As `text.setFormat` keeps a paragraph's indents, within 1296 pt either way. + let bound = |v: Option<f64>| v.map(|v| v.clamp(-1296.0, 1296.0)); + let entries: Vec<Value> = changes + .into_iter() + .filter_map(|(p, left, first_line)| { + let (left, first_line) = (bound(left), bound(first_line)); + let own_left = p.left - p.level; + if left.is_none_or(|v| v == own_left) && first_line.is_none_or(|v| v == p.first_line) { + return None; + } + let mut entry = json!({"id": p.id, "start": p.start}); + if let Some(v) = left { + entry["leftIndent"] = json!(v); + } + if let Some(v) = first_line { + entry["firstLineIndent"] = json!(v); + } + Some(entry) + }) + .collect(); + if entries.is_empty() { + return; + } + super::character::end_typing(app); + let command = json!({"command": "text.setFormat", "params": {"paragraphIndents": entries}}); + if let Err(e) = app.run("command.batch", json!({"label": label, "commands": [command]})) { + app.status(e); } } diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index 97e8c8a00a..d6bcef15f1 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -617,6 +617,52 @@ fn indent_markers_on_selected_threaded_frames_follow_the_story_and_set_a_split_p } } +/// A marker drag over many paragraphs of a threaded story with mixed indents sends one +/// `text.setFormat` for all of them, in one undo step, and a drag that changes no paragraph runs +/// nothing. +#[test] +fn indent_markers_over_many_threaded_paragraphs_send_one_format_command() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 900, "height": 3000})).unwrap(); + let story = (1..=128).map(|i| format!("p{i}")).collect::<Vec<_>>().join("\n"); + let a = app.run("text.create", json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 100}})).unwrap()["id"] + .as_u64() + .unwrap(); + let b = app.run("text.thread.add", json!({"from": a, "rect": [300, 10, 200, 2900]})).unwrap()["id"].as_u64().unwrap(); + app.run("text.setFormat", json!({"ids": [b], "leftIndent": 10})).unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let before = story_paragraphs(&app, &[a, b]); + assert_eq!(before.len(), 128, "{before:?}"); + assert!(before.iter().any(|p| p.1 == 0.0) && before.iter().any(|p| p.1 == 10.0), "{before:?}"); + let steps = |app: &VectorcraftApp| (app.session.active().unwrap().history.undo.len(), app.session.journal.len()); + let drag = |app: &mut VectorcraftApp, marker, to| { + let (node, para, span) = current_para(app).unwrap(); + let targets = indent_targets(app, &node); + assert_eq!(targets.len(), 128); + set_marker(app, marker, to, ¶, span, &targets, false); + }; + let (undo, journal) = steps(&app); + drag(&mut app, IndentMarker::Upper, 60.0); + assert_eq!(steps(&app), (undo + 1, journal + 1)); + let (name, params) = app.session.journal.last().cloned().unwrap(); + assert_eq!(name, "command.batch"); + let commands = params["commands"].as_array().unwrap(); + assert_eq!(commands.len(), 1, "one command for every paragraph"); + assert_eq!(commands[0]["command"], "text.setFormat"); + assert_eq!(commands[0]["params"]["paragraphIndents"].as_array().unwrap().len(), 128); + let after = story_paragraphs(&app, &[a, b]); + for (was, now) in before.iter().zip(&after) { + assert_eq!((&now.0, now.1, now.2), (&was.0, was.1, 60.0), "{after:?}"); + } + // The same position again changes no paragraph. + drag(&mut app, IndentMarker::Upper, 60.0); + assert_eq!(steps(&app), (undo + 1, journal + 1), "nothing ran"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(story_paragraphs(&app, &[a, b]), before); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(story_paragraphs(&app, &[a, b]), after); +} + /// After Select All in threaded text, a paragraph split between two frames is one target. #[test] fn indent_markers_after_select_all_list_a_split_paragraph_once() { @@ -633,12 +679,12 @@ fn indent_markers_after_select_all_list_a_split_paragraph_once() { assert!(positions.windows(2).all(|w| w[0] < w[1]), "{positions:?}"); } -/// With the Selection tool on some frames of a threaded story, only the paragraphs with text in -/// those frames are listed, each once: a selected frame that starts inside a paragraph lists that -/// paragraph, and so does a selected frame that ends inside one. Text in a selected group is -/// listed with the story's frames, and an empty text object keeps its one paragraph. +/// With the Selection tool on any frame of a threaded story, the indent markers list every +/// paragraph of the story, each once in story order: a selected story is one text. Text in a +/// selected group is listed with the story's frames, and an empty text object keeps its one +/// paragraph. #[test] -fn indent_markers_on_some_threaded_frames_list_their_paragraphs_once() { +fn indent_markers_on_some_threaded_frames_list_the_whole_story_once() { let (mut app, [a, b, c]) = split_story_app(); let count = |app: &VectorcraftApp, id: u64| text_of(app, id).paragraph_count(); let targets_of = |app: &mut VectorcraftApp, ids: Vec<u64>| { @@ -646,29 +692,64 @@ fn indent_markers_on_some_threaded_frames_list_their_paragraphs_once() { let (node, _, _) = current_para(app).unwrap(); story_positions(&indent_targets(app, &node), &[a, b, c]) }; - // The continuing frame alone: its first paragraph is the one the frame before ends inside. - let only_b = targets_of(&mut app, vec![b]); - assert_eq!(only_b.len(), count(&app, b), "{only_b:?}"); - assert_eq!(only_b.first(), Some(&(1, 0)), "{only_b:?}"); - let only_a = targets_of(&mut app, vec![a]); - assert_eq!(only_a.len(), count(&app, a), "{only_a:?}"); - // Two frames that share a paragraph list it once, by the later frame's piece. - let both = targets_of(&mut app, vec![c, a, b]); - assert_eq!(both.len(), count(&app, a) + count(&app, b) + count(&app, c) - 2, "{both:?}"); - assert!(both.windows(2).all(|w| w[0] < w[1]), "{both:?}"); - // The first and last frames of the story, and a grouped empty text object besides. + let story = count(&app, a) + count(&app, b) + count(&app, c) - 2; + let all = targets_of(&mut app, vec![c, a, b]); + assert_eq!(all.len(), story, "two frames that share a paragraph list it once: {all:?}"); + assert!(all.windows(2).all(|w| w[0] < w[1]), "{all:?}"); + for frame in [a, b, c] { + assert_eq!(targets_of(&mut app, vec![frame]), all, "frame {frame} alone lists the whole story"); + } + // A grouped empty text object besides the last frame of the story. let empty = app.run("text.create", json!({"x": 10, "y": 900, "text": "", "area": {"width": 100, "height": 40}})).unwrap()["id"].as_u64().unwrap(); app.run("select.set", json!({"ids": [empty, c]})).unwrap(); let group = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap(); - app.run("select.set", json!({"ids": [group, a]})).unwrap(); - let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(a)).unwrap().clone(); + app.run("select.set", json!({"ids": [group]})).unwrap(); + let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(c)).unwrap().clone(); let targets = indent_targets(&app, &node); let ids: Vec<u64> = targets.iter().map(|p| p.id).collect(); assert_eq!(ids.iter().filter(|id| **id == empty).count(), 1, "{ids:?}"); - let story: Vec<IndentTarget> = targets.into_iter().filter(|p| p.id != empty).collect(); - let positions = story_positions(&story, &[a, b, c]); - assert_eq!(positions.len(), count(&app, a) + count(&app, c), "{positions:?}"); - assert!(positions.windows(2).all(|w| w[0] < w[1]), "{positions:?}"); + let in_story: Vec<IndentTarget> = targets.into_iter().filter(|p| p.id != empty).collect(); + assert_eq!(story_positions(&in_story, &[a, b, c]), all); +} + +/// A marker drag on one selected frame of a threaded story sets every paragraph of the story, in +/// its locked and hidden frames too, in one undo step, whether the paragraphs share their indents +/// or have their own. +#[test] +fn indent_markers_on_one_threaded_frame_set_its_locked_and_hidden_frames() { + let (mut app, [a, b, c]) = split_story_app(); + app.run("select.set", json!({"ids": [b]})).unwrap(); + app.run("object.lock", json!({})).unwrap(); + app.run("select.set", json!({"ids": [c]})).unwrap(); + app.run("object.hide", json!({})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + // Every paragraph has the same indents. + let (node, para, span) = current_para(&app).unwrap(); + let targets = indent_targets(&app, &node); + assert_eq!(targets.len(), 12); + let undo = app.session.active().unwrap().history.undo.len(); + set_marker(&mut app, IndentMarker::Upper, 30.0, ¶, span, &targets, false); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); + let shared = story_paragraphs(&app, &[a, b, c]); + assert_eq!(shared.len(), 12, "{shared:?}"); + assert!(shared.iter().all(|p| (p.1, p.2) == (0.0, 30.0)), "{shared:?}"); + // The paragraphs of the hidden frame have a left indent of their own. + app.run("text.setFormat", json!({"ids": [c], "leftIndent": 10})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + let before = story_paragraphs(&app, &[a, b, c]); + assert_eq!(before.len(), 12, "{before:?}"); + assert!(before.iter().any(|p| p.1 == 0.0) && before.iter().any(|p| p.1 == 10.0), "{before:?}"); + let (node, para, span) = current_para(&app).unwrap(); + let targets = indent_targets(&app, &node); + assert_eq!(targets.len(), 12); + let undo = app.session.active().unwrap().history.undo.len(); + set_marker(&mut app, IndentMarker::Upper, 60.0, ¶, span, &targets, false); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); + let after = story_paragraphs(&app, &[a, b, c]); + assert_eq!(after.iter().map(|p| &p.0).collect::<Vec<_>>(), before.iter().map(|p| &p.0).collect::<Vec<_>>()); + for (was, now) in before.iter().zip(&after) { + assert_eq!((now.1, now.2), (was.1, 60.0), "{} keeps its left indent and starts at 60 pt: {after:?}", now.0); + } } #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 62a4212472..572100189a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8236,6 +8236,28 @@ stops, the command changes nothing and records no undo step. `text.tabs.set` rej holds at least that many, so stops can be removed from a paragraph that holds more. It sorts the stops by position and keeps the first of stops at one position. +`text.setFormat {paragraphIndents: [{id, start, leftIndent?, firstLineIndent?}…]}` gives each paragraph indents of its +own in one call: each entry names one paragraph of a type object by the byte `start` where the paragraph begins, and +sets its `leftIndent`, its `firstLineIndent` or both, in points from -1296 to 1296. The call takes no other key, and +at most 100000 entries. It checks every entry before it changes anything, and fails on an empty list, an unknown key, an +entry with neither indent, a value that is not a number in range, an `id` that is not a type object, a `start` where no +paragraph of that object begins, and two entries for one paragraph with other values (the same entry twice counts once). +The whole change is one edit: each type object's paragraph styles are rebuilt once, and a threaded story flows through +its frames again once, however many of its paragraphs change, where one `text.setFormat {ids, start, end}` for each +paragraph flows it again after every paragraph. When no paragraph's indents change, the command records no edit and no +undo step. It returns `{ids}`, the type objects the entries name. The Tabs panel's indent markers use it when the +paragraphs they move have indents of their own, inside a `command.batch` that names the undo step after the marker. + +```json +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"paragraphIndents":[{"id":12,"start":0,"firstLineIndent":36},{"id":12,"start":40,"leftIndent":24,"firstLineIndent":12}]}}} +``` + +The single-value form of `text.setFormat` fails, naming the key, when a number such as `leftIndent` or `baselineShift` is +given as a string, a switch or a list, or a switch such as `underline` as anything but true or false; it changes nothing +then. A null value of any attribute leaves that attribute as it is, as an omitted one does, so a call whose attributes +are all null fails with "nothing to change" and records no edit and no undo step. A call whose values are the ones the +text already has succeeds and returns its `ids`, but records no edit and no undo step. + `text.tabs.get {start?}` and `paraStyle.new` / `paraStyle.redefine {id, start?}` read the paragraph at `start`; `paraStyle.list` counts paragraphs as uses, and `charStyle.list` counts runs; both count the type in the layers, opacity masks, symbols, graph designs, pattern tiles and the art of brushes. `charStyle.unused` and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5ec97e34c1..570a44e8c8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -657,7 +657,7 @@ since 2026-10-01. - **Names of new styles and symbols:** `charStyle.new` and `paraStyle.new` trim the white space around the name they are given, as `charStyle.rename` and `paraStyle.rename` do, and refuse a name that is then empty; Duplicate names its copy from the trimmed name. `symbol.new` and `symbol.duplicate` trim the name as Symbol Options does, and `library.use` adds a library's text style under its trimmed name. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Naming a new text style first:** Alt-click on New in the Character Styles or Paragraph Styles panel opens New Character Style or New Paragraph Style (`ui.newCharStyle`, `ui.newParaStyle`) with the name a plain click's style gets; OK makes the style from the text selected when the dialog opened, and Cancel adds none. Tests `panels::text_styles::tests::alt_clicking_new_character_style_asks_for_its_name` and `alt_clicking_new_paragraph_style_asks_for_its_name`. Public reference: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), which lists Alt-click for opening panel options and names Actions, Brushes, Swatches and Symbols as exceptions. - **Moving all tab stops:** in the Tabs panel, a stop dragged on the ruler with Ctrl held (Command on macOS) moves every stop by the same distance, in one `text.tabs.set` and one undo step; the stops keep their order, alignment and leaders, the ruler shows them moving during the drag, and a drag that would take the leftmost stop below 0 leaves it at 0, or the rightmost past 100,000 pt leaves it at 100,000 pt, with the other stops at their spacing from it. A drag that leaves every stop where it was records no undo step. Ctrl must be held when the drag starts. Tests `panels::tabs::tests::command_drag_moves_all_the_stops_together`, `command_drag_stops_at_zero_and_keeps_the_spacing`, `command_drag_stops_at_the_largest_position_and_keeps_the_spacing` and `a_drag_that_moves_no_stop_records_no_undo_step`. Public reference: [Manage tab stops](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/manage-tab-stops.html), which says that a Ctrl-drag (Windows) or Command-drag (macOS) of a tab moves all the tab stops at the same time. -- **Tabs indent markers on threaded frames:** when a marker drag gives the paragraphs of a threaded story values of their own, the Tabs panel sends one `text.setFormat` for each paragraph of the story, from its last paragraph to its first, whatever order the frames were selected in with the Selection tool; a paragraph that runs on from one frame into the next gets one step, after Type tool Select All too. With the Selection tool only the paragraphs with text in a selected frame change, so a frame selected alone that starts or ends inside a paragraph sets that paragraph. Tests `panels::tabs::tests::indent_markers_on_selected_threaded_frames_follow_the_story_and_set_a_split_paragraph_once`, `indent_markers_after_select_all_list_a_split_paragraph_once` and `indent_markers_on_some_threaded_frames_list_their_paragraphs_once`. Public reference: [Indent text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/indent-text.html). +- **Tabs indent markers on threaded frames:** when a marker drag gives the paragraphs of a threaded story values of their own, the Tabs panel sends one `text.setFormat {paragraphIndents}` with an entry for each paragraph of the story, whatever order the frames were selected in with the Selection tool, so the story flows through its frames again once rather than once for each paragraph, and a drag that changes no paragraph runs nothing; a paragraph that runs on from one frame into the next gets one entry, after Type tool Select All too. A selected story is one text: with the Selection tool, a marker drag on any of its frames sets every paragraph of the story, in its locked and hidden frames too. Tests `panels::tabs::tests::indent_markers_on_selected_threaded_frames_follow_the_story_and_set_a_split_paragraph_once`, `indent_markers_over_many_threaded_paragraphs_send_one_format_command`, `indent_markers_after_select_all_list_a_split_paragraph_once`, `indent_markers_on_some_threaded_frames_list_the_whole_story_once`, `indent_markers_on_one_threaded_frame_set_its_locked_and_hidden_frames`, `cmd::threads::tests::paragraph_indents_flow_a_threaded_story_once_as_one_command_per_paragraph_would`, `paragraph_indents_over_thousands_of_paragraphs_flow_the_story_once` and `paragraph_indents_check_every_entry_and_skip_a_change_that_changes_nothing`, and `cmd::typecmd::area_tests::set_format_refuses_a_param_of_the_wrong_type`. Public reference: [Indent text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/indent-text.html). - **Width Point Edit:** Shift-double-click enables Adjust Adjoining Width Points; plain double-click and direct opening retain the default. Tool dispatch and headless dialog tests cover neighboring widths, Undo/Redo and Cancel. Public reference: [Variable-width strokes](https://helpx.adobe.com/illustrator/using/stroke-object.html). Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a feature lands. From 451c308ed49de1dc3e097645d1c648e08f2178fe Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 09:27:59 -0800 Subject: [PATCH 75/92] Remove Background: a gray image colorized with a process color is refused A process color can't colorize a grayscale image that has an alpha channel, and paint.setFill refuses that combination. Remove Background gave such an image an alpha channel and kept its process colorize color. It now refuses the run, changing nothing, when an image it is given is gray and colorized with a process color. It checks this before any image is decoded. Another image in the document that already has that combination does not affect a run on other images. Black, white and spot colors still colorize the result. The rule check_colorized applies to each image is now one function, paint::gray_colorized_with_process, which both checks call. Docs: docs/mcp.md (Remove Background), docs/target-app-parity.md. --- crates/engine/src/cmd/addons/image.rs | 11 ++++- crates/engine/src/cmd/addons/image/tests.rs | 51 ++++++++++++++++++++- crates/engine/src/cmd/paint.rs | 18 +++++--- docs/mcp.md | 8 ++-- docs/target-app-parity.md | 2 +- 5 files changed, 76 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/addons/image.rs b/crates/engine/src/cmd/addons/image.rs index b2a9d74f83..bfe3d2f4ef 100644 --- a/crates/engine/src/cmd/addons/image.rs +++ b/crates/engine/src/cmd/addons/image.rs @@ -20,6 +20,7 @@ use vectorcraft_geom::Affine; use super::super::fileio::{MAX_RASTER_ALLOC, ppi}; use super::super::menucmds::has_image; +use super::super::paint::{PROCESS_ON_TRANSPARENCY, gray_colorized_with_process}; use super::super::{CommandSpec, Result, Session, bad, checked_ids_param, cmd, hex_with_alpha, number_or, switch_or, text_param}; /// Remove Background's command. @@ -39,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> { "Remove Background…", ["Object", "Plug-ins", "Image Tools"], None, - "{background?: \"edges\" (default: the most common color along the image's edges; nothing is cleared when the edges hold at least as many clear pixels as pixels of that color) | \"corners\" (the average of the image's four corner pixels that are not clear; nothing is cleared when all four are clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 colors, each a hex color \"#rrggbb\" or \"#rgb\" with or without the # or one of black, white, red, green and blue; needed with background: colors, and checked but not used with edges or corners), contiguous?: bool (true: only background pixels that reach in from an edge are cleared; false: they are cleared wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a background color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (with distance, % of the largest RGB distance; with channels, % of 255 steps on one channel; a pixel this near a background color is cleared; 10), softness?: 0..100 (% past the tolerance over which pixels fade from cleared to kept; 10), ids?: [id…] (default: the selected images)} make the background of each image transparent in its own pixels, as one undo step: the image stays an image, embedded as a PNG with an alpha channel at the depth of its pixels (16-bit images keep 16 bits and grayscale images stay gray) with the source's color profile and resolution, and a linked image is embedded; a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, and a result with no pixel left are refused, and an image whose pixels do not change is left as it is (no undo step when none changes) → {changed: the images changed, ids, cleared: the pixels made fully clear in them}", + "{background?: \"edges\" (default: the most common color along the image's edges; nothing is cleared when the edges hold at least as many clear pixels as pixels of that color) | \"corners\" (the average of the image's four corner pixels that are not clear; nothing is cleared when all four are clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 colors, each a hex color \"#rrggbb\" or \"#rgb\" with or without the # or one of black, white, red, green and blue; needed with background: colors, and checked but not used with edges or corners), contiguous?: bool (true: only background pixels that reach in from an edge are cleared; false: they are cleared wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a background color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (with distance, % of the largest RGB distance; with channels, % of 255 steps on one channel; a pixel this near a background color is cleared; 10), softness?: 0..100 (% past the tolerance over which pixels fade from cleared to kept; 10), ids?: [id…] (default: the selected images)} make the background of each image transparent in its own pixels, as one undo step: the image stays an image, embedded as a PNG with an alpha channel at the depth of its pixels (16-bit images keep 16 bits and grayscale images stay gray) with the source's color profile and resolution, and a linked image is embedded; a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, a result with no pixel left, and a grayscale image colorized with a process color (which can't colorize gray pixels with transparency) are refused, and an image whose pixels do not change is left as it is (no undo step when none changes) → {changed: the images changed, ids, cleared: the pixels made fully clear in them}", has_image, remove_background )] @@ -182,8 +183,14 @@ fn changes(s: &Session, p: &Value, settings: &Settings, mut kept: Option<&mut De // Every image is checked before any is decoded. let mut images = vec![]; for id in ids { - let Some(NodeKind::Image(im)) = st.doc.node(id).map(|n| &n.kind) else { continue }; + let Some(n) = st.doc.node(id) else { continue }; + let NodeKind::Image(im) = &n.kind else { continue }; let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(C, "the image's pixels are missing"))?; + // The result has an alpha channel, which a grayscale image colorized with a process color + // can't take. + if gray_colorized_with_process(&st.doc, n).is_some() { + return Err(bad(C, PROCESS_ON_TRANSPARENCY)); + } // The preview stands in for a file that hasn't been read, or that is kept at its preview. if im.link.is_some() && blob.is_proxy() { return Err(bad(C, "the linked image shows its preview: embed or relink it first")); diff --git a/crates/engine/src/cmd/addons/image/tests.rs b/crates/engine/src/cmd/addons/image/tests.rs index df27180e82..d57f58046f 100644 --- a/crates/engine/src/cmd/addons/image/tests.rs +++ b/crates/engine/src/cmd/addons/image/tests.rs @@ -1,8 +1,9 @@ use std::io::Cursor; use std::sync::Arc; -use image::{DynamicImage, ImageBuffer, Luma, Rgb}; +use image::{DynamicImage, ImageBuffer, Luma, LumaA, Rgb}; use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind}; use vectorcraft_geom::Affine; @@ -333,6 +334,54 @@ fn sixteen_bit_and_gray_images_keep_their_depth_and_channels() { assert_eq!((out.get_pixel(0, 0).0, out.get_pixel(3, 3).0), ([61_000, 0], [7_000, 65_535])); } +/// A process color can't colorize a grayscale image with transparency, so a gray image colorized +/// with one keeps its pixels. Black, white and spot colors can, so those images are cleared. +#[test] +fn a_gray_image_colorized_with_a_process_color_is_refused() { + let gray = || { + png(DynamicImage::ImageLuma8(ImageBuffer::from_fn(8, 8, |x, y| Luma([if (2..6).contains(&x) && (2..6).contains(&y) { 30u8 } else { 240 }])))) + }; + let (mut s, id) = open(&gray()); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "the fill colorizes the image"); + let (before, steps) = (s.doc().unwrap().doc.clone(), undo_len(&s)); + let e = s.execute(C, &json!({})).unwrap_err(); + assert!(e.to_string().contains("a process color can't colorize a grayscale image with transparency"), "{e}"); + assert_eq!((&s.doc().unwrap().doc, undo_len(&s)), (&before, steps), "the document and its undo steps are unchanged"); + // Another image that already has an alpha channel and a process colorize color, as an opened + // file can hold, neither lets this one through nor stops a run on an image that can be cleared. + let held = ImageBlob::png(png(DynamicImage::ImageLumaA8(ImageBuffer::from_pixel(2, 2, LumaA([128u8, 100]))))); + let other = place_linked(&mut s, "held", held, (2, 2)); + s.edit("Embed and colorize", |d, _| { + let n = d.node_mut(other).unwrap(); + n.extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb8(255, 0, 0)))); + if let NodeKind::Image(im) = &mut n.kind { + im.link = None; + } + Ok(()) + }) + .unwrap(); + let (before, steps) = (s.doc().unwrap().doc.clone(), undo_len(&s)); + let e = s.execute(C, &json!({"ids": [id.0]})).unwrap_err(); + assert!(e.to_string().contains("a process color can't colorize a grayscale image with transparency"), "{e}"); + assert_eq!((&s.doc().unwrap().doc, undo_len(&s)), (&before, steps), "with another image refused already, nothing changes"); + s.execute("paint.setFill", &json!({"color": "#000000", "ids": [id.0]})).unwrap(); + assert_eq!(s.execute(C, &json!({"ids": [id.0]})).unwrap()["changed"], 1, "an image colorized with black is cleared beside the other"); + let DynamicImage::ImageLumaA8(out) = pixels(&s, id) else { panic!("gray with alpha") }; + assert_eq!(out.get_pixel(0, 0).0[1], 0, "the background is cleared"); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "the image stays colorized"); + for fill in [json!({"color": "#000000"}), json!({"color": "#ffffff"}), json!({"swatch": "Ink"})] { + let (mut s, id) = open(&gray()); + s.execute("swatch.new", &json!({"name": "Ink", "color": {"c": 0, "m": 100, "y": 100, "k": 0}, "spot": true})).unwrap(); + s.execute("paint.setFill", &fill).unwrap(); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "{fill} colorizes the image"); + s.execute(C, &json!({})).unwrap(); + let DynamicImage::ImageLumaA8(out) = pixels(&s, id) else { panic!("gray with alpha") }; + assert_eq!(out.get_pixel(0, 0).0[1], 0, "{fill}: the background is cleared"); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "{fill} still colorizes the image"); + } +} + /// The ICC profile the colors are in stays with them. #[test] fn the_color_profile_is_kept() { diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index c3b24463ae..1aeba3ad30 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -4,7 +4,7 @@ use std::borrow::Cow; use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, GradientPaint, Paint}; -use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Appearance, CharStyle, Document, GraySource, Node, NodeId, NodeKind}; use super::appearance::{ItemTarget, appearance_targets, edit_items, edit_items_then, item_target}; use super::gradient::{item_paint_bounds, place_paint, place_run_paint, run_paint_mut, run_stroke_weight, unplaced}; @@ -718,7 +718,8 @@ fn colorized_images(d: &Document, ids: &[NodeId], paint: &Paint) -> std::result: } /// The message for a process color on a grayscale image with an alpha channel. -const PROCESS_ON_TRANSPARENCY: &str = "a process color can't colorize a grayscale image with transparency: use a spot color, black or white"; +pub(super) const PROCESS_ON_TRANSPARENCY: &str = + "a process color can't colorize a grayscale image with transparency: use a spot color, black or white"; /// Can `paint` colorize a grayscale image with an alpha channel: a color linked to a spot swatch /// of `d`, black or white? @@ -737,11 +738,7 @@ pub(super) fn check_colorized(d: &Document) -> std::result::Result<(), String> { let mut refused = false; let mut visit = |n: &Node| { vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| { - if let (NodeKind::Image(im), Some(paint)) = (&m.kind, m.extras().colorize.as_deref()) - && !refused - && !colorizes_transparency(d, paint) - && d.image_blob(im).and_then(|b| b.gray_source()).is_some_and(|g| g.alpha) - { + if !refused && gray_colorized_with_process(d, m).is_some_and(|g| g.alpha) { refused = true; } }) @@ -761,6 +758,13 @@ pub(super) fn check_colorized_kept(was_ok: bool, d: &Document) -> std::result::R if was_ok { check_colorized(d) } else { Ok(()) } } +/// How the pixels of `n` are gray, when `n` is a grayscale or 1-bit image colorized with a color +/// that [`colorizes_transparency`] refuses. Such an image can't have an alpha channel. +pub(super) fn gray_colorized_with_process(d: &Document, n: &Node) -> Option<GraySource> { + let (NodeKind::Image(im), Some(paint)) = (&n.kind, n.extras().colorize.as_deref()) else { return None }; + if colorizes_transparency(d, paint) { None } else { d.image_blob(im).and_then(|b| b.gray_source()) } +} + /// Is `e` the refusal of [`check_colorized`]? pub(super) fn is_colorize_refusal(e: &EngineError) -> bool { e.to_string().contains(PROCESS_ON_TRANSPARENCY) diff --git a/docs/mcp.md b/docs/mcp.md index 672e730095..348d184fac 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -10001,9 +10001,11 @@ change in one undo step, and an image that `ids` names twice changes once. An im as it is, and when none changes the command adds no undo step and returns `changed: 0`. `cleared` counts the pixels made fully clear in the images that changed. The command refuses, and changes nothing, when an image is CMYK, a linked image shows its preview (embed or relink it first), an image would need more than the decode allowance while it is -worked on (2 GiB, or 512 MiB in the web app, counted from the file's header before it is decoded), or every pixel of an -image would be cleared. Remove Background… opens the command's dialog with Preview off; with Preview on, the canvas -shows the result for the values in the dialog, and Cancel puts the image back. While a dialog previews the command, +worked on (2 GiB, or 512 MiB in the web app, counted from the file's header before it is decoded), an image is +grayscale and colorized with a process color (the result has an alpha channel, and only a spot color, black or white +colorizes a grayscale image with transparency, as `paint.setFill` requires), or every pixel of an image would be +cleared. Remove Background… opens the command's dialog with Preview off; with Preview on, the canvas shows the result +for the values in the dialog, and Cancel puts the image back. While a dialog previews the command, the session keeps the decoded pixels of the images it changes (at most 128 MiB, or 32 MiB in the web app), so the next preview step does not decode them again; it releases them when the dialog closes with OK or Cancel. A run that is not a preview step does not keep them. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b172aefd7e..9491723162 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -2015,7 +2015,7 @@ A VectorCraft addition (it replaces a script some users ran), so no area score m ## Image Tools add-on: Remove Background (2026-11-11) -A VectorCraft addition, so no area score moves. Object › Plug-ins › Image Tools › Remove Background… (`addon.image.removeBackground`) makes the background of the selected images transparent in their own pixels, in the app with no service or model. The background is the most common edge color (nothing is cleared when the edges hold at least as many clear pixels as pixels of that color), the average of the corners that are not clear, or picked colors. Contiguous (on by default) clears it only where it reaches in from an edge; off, it is cleared everywhere. Tolerance and Softness (0 to 100, 10 by default, VectorCraft's own values) set how near a color is cleared and the band over which pixels fade out, measured as RGB distance or, with Match set to Each Channel, as the largest difference on one channel. The run is one undo step, and none is recorded when no pixel changes. The image stays an image: a PNG with alpha at its own bit depth, gray or color as it was, with its color profile and resolution, and a linked image is embedded. CMYK images, linked images that show their preview, images that would need more than the decode allowance and results with no pixel left are refused. The dialog's Preview works as Release Type on a Path's does; its preview steps decode an image once, and the decoded pixels are released when the dialog closes. Tests: `cmd::addons::image::tests` in `vectorcraft-engine` (edge, corner and picked colors, contiguous on and off, a second run, tolerance and softness by distance and by channel, 16-bit and gray images, the color profile and resolution kept, an image read at another size, linked images, refusals and bad params, and decoded pixels reused across preview steps and released at OK and Cancel), `addons::image::tests::remove_background_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Image Tools add-on. Since 2026-11-12 a selected image also offers Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar, where the reference app offers it (<https://community.adobe.com/announcements-651/ai-powered-image-editing-features-in-illustrator-1624061>); a click runs it at once with the defaults, as one undo step (tests `canvas::tests::the_task_bar_removes_a_selected_images_background` and `addons::image::tests::properties_and_the_control_bar_remove_the_background_at_once`). +A VectorCraft addition, so no area score moves. Object › Plug-ins › Image Tools › Remove Background… (`addon.image.removeBackground`) makes the background of the selected images transparent in their own pixels, in the app with no service or model. The background is the most common edge color (nothing is cleared when the edges hold at least as many clear pixels as pixels of that color), the average of the corners that are not clear, or picked colors. Contiguous (on by default) clears it only where it reaches in from an edge; off, it is cleared everywhere. Tolerance and Softness (0 to 100, 10 by default, VectorCraft's own values) set how near a color is cleared and the band over which pixels fade out, measured as RGB distance or, with Match set to Each Channel, as the largest difference on one channel. The run is one undo step, and none is recorded when no pixel changes. The image stays an image: a PNG with alpha at its own bit depth, gray or color as it was, with its color profile and resolution, and a linked image is embedded. CMYK images, linked images that show their preview, images that would need more than the decode allowance, results with no pixel left and grayscale images colorized with a process color are refused (the result has an alpha channel, and a process color can't colorize a grayscale image with one: [Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"). The dialog's Preview works as Release Type on a Path's does; its preview steps decode an image once, and the decoded pixels are released when the dialog closes. Tests: `cmd::addons::image::tests` in `vectorcraft-engine` (edge, corner and picked colors, contiguous on and off, a second run, tolerance and softness by distance and by channel, 16-bit and gray images, the color profile and resolution kept, an image read at another size, linked images, refusals and bad params, and decoded pixels reused across preview steps and released at OK and Cancel), `addons::image::tests::remove_background_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Image Tools add-on. Since 2026-11-12 a selected image also offers Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar, where the reference app offers it (<https://community.adobe.com/announcements-651/ai-powered-image-editing-features-in-illustrator-1624061>); a click runs it at once with the defaults, as one undo step (tests `canvas::tests::the_task_bar_removes_a_selected_images_background` and `addons::image::tests::properties_and_the_control_bar_remove_the_background_at_once`). ## Color Effects add-on: Find Nearest Swatch (2026-11-01) From 74e61c9b74921536044bb0e0eeede5b37f116f66 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 10:04:31 -0800 Subject: [PATCH 76/92] Image Trace: Flat Logo judges flat art by the image's own colors With logoColors named, Flat Logo measured how flat the image is against the named colors. A solid pixel counts as flat only within 22 (RGB distance) of one of those colors or white. Flat art whose named colors were 20 levels off in every channel was refused as not flat, and the refusal score of a gradient changed with the names. The check now uses the image's own colors, found the same way as when no colors are named. It looks for as many colors as are named, with white, or as the Colors setting gives, whichever is more. How far the named colors are from the image's colors no longer changes whether the image is refused. This changes one existing behavior: an image with large areas of a color that was not named used to be refused, and it is now traced in the named colors. Docs: docs/image-trace-adjustments.md, docs/target-app-parity.md, the imageTrace.make params text. --- crates/engine/src/cmd/buildcmds.rs | 2 +- crates/trace/src/logo/pipeline.rs | 17 +++- crates/trace/src/logo/tests.rs | 138 +++++++++++++++++++++++++++++ crates/trace/src/params.rs | 6 +- docs/image-trace-adjustments.md | 10 +++ docs/target-app-parity.md | 1 + 6 files changed, 167 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 53e1b655a1..94fe55c119 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> { "Make", ["Object", "Image Trace"], None, - "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}", + "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused, judged by the image's own colors also when logoColors are named), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}", has_selection_or_ids, |s, p| trace_make(s, p, false) ), diff --git a/crates/trace/src/logo/pipeline.rs b/crates/trace/src/logo/pipeline.rs index b8382c5a7f..766cf242dd 100644 --- a/crates/trace/src/logo/pipeline.rs +++ b/crates/trace/src/logo/pipeline.rs @@ -386,7 +386,10 @@ type Colours = Vec<[u8; 3]>; /// The colours to trace: those the caller named (white is skipped when it is its own class; /// colours closer than [`SAME_COLOUR`] count as one; at most [`MAX_COLOURS`]) or the ones found, -/// the tones of one colour folded into it ([`fold_tones`]); and with them the colours before folding. +/// the tones of one colour folded into it ([`fold_tones`]); and with them the image's own colors +/// before folding. With named colors, the image's own colors are found the same way as when none are +/// named, and as many are looked for as are named, with white, or as the Colors setting gives, +/// whichever is more. fn choose_palette(params: &TraceParams, pix: &[Px], w: usize, h: usize) -> Result<(Colours, Colours), TraceError> { if params.logo_colors.is_empty() { let found = auto_palette(pix, w, h, (params.colors as usize).clamp(2, MAX_COLOURS)); @@ -401,7 +404,12 @@ fn choose_palette(params: &TraceParams, pix: &[Px], w: usize, h: usize) -> Resul palette.push(c); } } - Ok((palette.clone(), palette)) + if palette.is_empty() { + return Ok((palette, Vec::new())); + } + // (One more than named: white takes a slot while the colors are found, and is left out after.) + let tones = auto_palette(pix, w, h, (palette.len() + 1).max(params.colors as usize).clamp(2, MAX_COLOURS + 1)); + Ok((palette, tones)) } /// Trace a flat-colour logo. @@ -411,8 +419,9 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize) return Ok(TraceResult::default()); } let (mut pix, mut w, mut h, mut factor) = working_copy(img, 1); - // (`tones`: the colours as found, each tone of an unevenly lit colour on its own. Whether the - // image is flat art is asked of them: a paper's tones are flat colour too.) + // (`tones`: the image's own colors as found, each tone of an unevenly lit colour on its own, + // also when colors are named. Whether the image is flat art is asked of them: a paper's tones + // are flat colour too, and how far the named colors are from the image's does not change the answer.) let (mut palette, tones) = choose_palette(params, &pix, w, h)?; if palette.is_empty() { return Ok(TraceResult::default()); diff --git a/crates/trace/src/logo/tests.rs b/crates/trace/src/logo/tests.rs index 8d3f6a79fc..168ba05d7f 100644 --- a/crates/trace/src/logo/tests.rs +++ b/crates/trace/src/logo/tests.rs @@ -372,6 +372,144 @@ fn named_colors_a_few_levels_off_trace_an_image_up_to_1024_px_at_full_size() { } } +/// A dark ring around a tan disc with a blue square on it, hard edges, on a transparent background. +fn hard_badge() -> Raster { + Raster::from_fn(160, 160, |x, y| { + let (fx, fy) = (f64::from(x) + 0.5, f64::from(y) + 0.5); + let d = (fx - 80.0).hypot(fy - 80.0); + if (60.0..=100.0).contains(&fx) && (60.0..=100.0).contains(&fy) { + [34, 124, 250, 255] + } else if d <= 60.0 { + [200, 170, 120, 255] + } else if d <= 68.0 { + [4, 4, 3, 255] + } else { + [0, 0, 0, 0] + } + }) +} + +#[test] +fn flat_art_is_traced_with_named_colors_far_from_its_own() { + // Every named color is 20 levels off the image's in each channel: the image is still flat art, + // and each area takes the named color nearest it. + let names = ["#181817", "#B49664", "#0E68E6"]; + let res = trace(&hard_badge(), &TraceParams { logo_colors: names.iter().map(|s| s.to_string()).collect(), ..logo_params() }); + assert_eq!(res.paths.len(), 3, "{:?}", res.paths.iter().map(|p| p.color).collect::<Vec<_>>()); + assert_eq!(top_color(&res, Point::new(80.0, 80.0)), Some([14, 104, 230])); + assert_eq!(top_color(&res, Point::new(80.0, 30.0)), Some([180, 150, 100])); + assert_eq!(top_color(&res, Point::new(80.0, 15.0)), Some([24, 24, 23])); +} + +#[test] +fn art_that_is_not_flat_is_refused_by_its_own_colors_whatever_is_named() { + let img = Raster::from_fn(64, 32, |x, y| [(x * 4) as u8, 255 - (x * 4) as u8, (y * 6) as u8, 255]); + let refused = |p: &TraceParams| match trace_within(&img, p, 100_000) { + Err(TraceError::NotFlat { fraction }) => fraction, + other => panic!("expected NotFlat, got {other:?}"), + }; + let own = refused(&logo_params()); + // Names close to some of the gradient's colors, and names far from all of them. + for names in [["#08F710", "#80807F", "#F00C60"], ["#FF00FF", "#00FFFF", "#FFFF00"]] { + let named = TraceParams { logo_colors: names.iter().map(|s| s.to_string()).collect(), ..logo_params() }; + assert_eq!(refused(&named), own, "names {names:?}"); + assert_eq!(refused(&TraceParams { logo_white: false, ..named }), own, "names {names:?} without white"); + } +} + +#[test] +fn a_hatch_gives_the_same_result_with_close_and_far_named_colors() { + // Black lines one pixel wide across, blue lines down, on a cream ground. + let img = render(120, 120, 0.5, |x, y| { + Some(if y.rem_euclid(8.0) < 1.0 { + [12, 12, 12] + } else if x.rem_euclid(8.0) < 1.0 { + [30, 70, 200] + } else { + [236, 226, 196] + }) + }); + let outcome = |names: [&str; 3]| match trace_within( + &img, + &TraceParams { logo_colors: names.iter().map(|s| s.to_string()).collect(), ..logo_params() }, + 100_000, + ) { + Ok(res) => Ok(res.paths.len()), + Err(TraceError::NotFlat { fraction }) => Err(fraction), + Err(e) => panic!("{e:?}"), + }; + let close = outcome(["#0C0C0C", "#1E46C8", "#ECE2C4"]); + // Every channel 20 levels off. + assert_eq!(outcome(["#202020", "#325ADC", "#D8CEB0"]), close); +} + +#[test] +fn more_named_colors_than_the_colors_setting_do_not_make_the_refusal_depend_on_the_names() { + let img = Raster::from_fn(64, 32, |x, y| [(x * 4) as u8, 255 - (x * 4) as u8, (y * 6) as u8, 255]); + let refused = |p: &TraceParams| match trace_within(&img, p, 100_000) { + Err(TraceError::NotFlat { fraction }) => fraction, + other => panic!("expected NotFlat, got {other:?}"), + }; + // Six names with Colors at 2: seven of the image's own colors are looked for (six and white), as + // with Colors at 7. + let own = refused(&TraceParams { colors: 7, ..logo_params() }); + for names in + [["#00FF00", "#40BF3C", "#807F78", "#C03FB4", "#FC03BA", "#649B1E"], ["#FF00FF", "#00FFFF", "#FFFF00", "#000000", "#FF0000", "#0000FF"]] + { + let named = TraceParams { logo_colors: names.iter().map(|s| s.to_string()).collect(), colors: 2, ..logo_params() }; + assert_eq!(refused(&named), own, "names {names:?}"); + } +} + +#[test] +fn areas_of_colors_that_are_not_named_are_traced_in_the_named_colors() { + // Red, green, blue and yellow bands; only red and blue are named. + const BANDS: [[u8; 3]; 4] = [[200, 30, 30], [30, 160, 60], [60, 70, 180], [230, 200, 40]]; + let img = Raster::from_fn(160, 40, |x, _| { + let c = BANDS[(x / 40) as usize]; + [c[0], c[1], c[2], 255] + }); + let res = trace(&img, &TraceParams { logo_colors: vec!["#C81E1E".into(), "#3C46B4".into()], ..logo_params() }); + for p in &res.paths { + assert!([BANDS[0], BANDS[2], [255, 255, 255]].contains(&p.color), "{:?}", p.color); + } + assert_eq!(top_color(&res, Point::new(20.0, 20.0)), Some(BANDS[0])); + assert_eq!(top_color(&res, Point::new(100.0, 20.0)), Some(BANDS[2])); +} + +#[test] +fn flat_art_on_white_with_more_named_colors_than_the_colors_setting_is_flat() { + // Red, green and blue bands and a white one, the three colors named, with Colors at 2: white + // does not take the place of a named color while the image's colors are found. + const BANDS: [[u8; 3]; 4] = [[220, 30, 30], [30, 180, 60], [40, 60, 210], [255, 255, 255]]; + let img = Raster::from_fn(160, 40, |x, _| { + let c = BANDS[(x / 40) as usize]; + [c[0], c[1], c[2], 255] + }); + let names = vec!["#DC1E1E".to_string(), "#1EB43C".to_string(), "#283CD2".to_string()]; + let res = trace(&img, &TraceParams { logo_colors: names, colors: 2, ..logo_params() }); + for (i, c) in BANDS.iter().enumerate() { + assert_eq!(top_color(&res, Point::new(20.0 + 40.0 * i as f64, 20.0)), Some(*c), "band {i}"); + } +} + +#[test] +fn flat_art_with_more_named_colors_than_the_colors_setting_is_flat() { + // Eight flat bands, each color named, with Colors at 2: as many of the image's own colors are + // looked for as are named, with white, so the art is still flat. + const BANDS: [[u8; 3]; 8] = + [[200, 30, 30], [30, 160, 60], [60, 70, 180], [230, 200, 40], [120, 40, 160], [20, 20, 20], [240, 130, 30], [40, 190, 200]]; + let img = Raster::from_fn(160, 40, |x, _| { + let c = BANDS[(x / 20) as usize]; + [c[0], c[1], c[2], 255] + }); + let names: Vec<String> = BANDS.iter().map(|c| format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2])).collect(); + let res = trace(&img, &TraceParams { logo_colors: names, colors: 2, ..logo_params() }); + for (i, c) in BANDS.iter().enumerate() { + assert_eq!(top_color(&res, Point::new(10.0 + 20.0 * i as f64, 20.0)), Some(*c), "band {i}"); + } +} + /// Every anchor of every path is finite and inside (a small margin around) the image. fn assert_sane(res: &TraceResult, w: u32, h: u32) { for t in &res.paths { diff --git a/crates/trace/src/params.rs b/crates/trace/src/params.rs index aed7984d85..6073fa04fe 100644 --- a/crates/trace/src/params.rs +++ b/crates/trace/src/params.rs @@ -120,8 +120,10 @@ pub struct TraceParams { pub stroke_width: f64, /// Flat Logo mode: the flat colours (`#rrggbb`) to use; empty finds them automatically. White is /// traced on its own and cannot be named unless [`logo_white`](Self::logo_white) is off, colours - /// closer than about 12 (RGB distance) count as one, and at most ten are used. (Not `palette`: - /// that is Color mode's colour source.) + /// closer than about 12 (RGB distance) count as one, and at most ten are used. Whether the image + /// is flat art (and not refused) is judged by its own colors, which are found the same way as when + /// no colors are named; as many are looked for as are named, with white, or as + /// [`colors`](Self::colors) gives, whichever is more. (Not `palette`: that is Color mode's colour source.) pub logo_colors: Vec<String>, /// Flat Logo mode with named `logo_colors`: `true` traces white (every channel 245 or more) as a /// color of its own beside the named ones. `false` traces the named colors only: white areas diff --git a/docs/image-trace-adjustments.md b/docs/image-trace-adjustments.md index 77086c5031..11de4fbda6 100644 --- a/docs/image-trace-adjustments.md +++ b/docs/image-trace-adjustments.md @@ -87,6 +87,16 @@ pixels and 16,384 pixels on a side. A raster export shows the line as the canvas Type Optimized anti-aliasing, or when it uses Art Optimized and is larger than either limit. Overlapping stacks the colors instead: a lower color continues under the edge of the color above it, and no background shows along that edge. + +**Flat Logo with named colors.** With `logoColors` named, Flat Logo decides whether the image is flat +art by the image's own colors. It finds them the same way as when no colors are named, and looks for as +many colors as are named, with white, or as the Colors setting gives, whichever is more. How far the named colors +are from the image's colors does not change whether the image is refused. While no more colors are +named than the Colors setting, an image is refused exactly when it is refused without named colors. +The trace then uses only the named colors, and white while white is a color of its own. An area of a +color that was not named is traced too: each of its pixels goes to the named color, white or +transparent that has the largest share in the blend of them that comes closest to the pixel. + **Large images.** An image of more than 1500 pixels on a side is traced as any other, only more slowly, and the trace says so: `imageTrace.make` returns the note in `warnings` and the status bar shows it (the [results optimization](https://helpx.adobe.com/illustrator/using/image-trace-results-optimization.html) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 468dce2e91..3b7505610b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1282,6 +1282,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Several mesh points at once (#4207, #4208, #4170):** the Mesh tool and Direct Selection pick several points of a gradient mesh or mesh envelope: Shift-click adds a point or takes a picked one away, Direct Selection's marquee picks the points inside it (Shift toggles them) and selects their meshes, and its click on a mesh line picks the line's two ends and one inside a patch the four points around it. The picked points show filled; dragging one moves them all as one undo step and the arrow keys nudge them (`object.mesh.movePoints {id, points, dx, dy}` or `{items, dx, dy}`), and a click on one of several picked points picks it alone. A solid fill colour (`paint.setFill`: Color panel, Swatches, Eyedropper) recolours every picked point, and the Transparency panel's Opacity fades them instead of the object, the panel showing their opacity (blank when it differs); `object.mesh.setPointColor` takes `points` or `items`. Public reference: [Edit mesh objects](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-meshes/edit-mesh-objects.html) (Shift selects multiple mesh points; a fill colour colours the selected point), [Macworld, gradient mesh](https://www.macworld.com/article/202693/gmesh2.html) (Direct Selection: a mesh line selects the points at both ends, a patch the points around it, Shift-click adds or removes points), [Peachpit](https://www.peachpit.com/articles/article.aspx?p=1638270) (each mesh point can have its own colour or transparency). Tests: `shift_clicks_pick_mesh_points_that_drag_and_nudge_together` and `direct_selection_picks_mesh_points_by_marquee_line_and_patch` in `crates/tools/src/meshedit.rs`, `picked_mesh_points_take_the_fill_opacity_and_drag_together` in `crates/engine/src/tests/editcolors.rs`, `mesh_points_move_and_colour_together` in `crates/engine/src/tests/live.rs`. Scores and hours unchanged. - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. Line art whose only solid area is white paper (strokes a pixel or two wide) takes its colours from the strokes' cores, with the lighter shades along a stroke's anti-aliased edge folded into its ink; a page whose ink (a channel at least 48 below white) covers under 1% of it traces to nothing, as blank paper does. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. - **Image Trace, Flat Logo named colors only:** with `logoColors` named, `"logoWhite": false` traces those colors only, so white areas take the nearest of them and no white is added; a named white is kept, and enclosed transparent areas stay holes; code-drawn tests in `crates/trace` and `crates/engine`. +- **Image Trace, Flat Logo flat-art check with named colors:** with `logoColors` named, whether the image is flat art is judged by its own colors, as many as are named, with white, or as the Colors setting gives, whichever is more. Named colors 20 levels off a flat badge's colors still trace it, a hatch on a light ground gives the same result with close and far named colors, a gradient's refusal score does not depend on how far the named colors are from it, and areas of colors that were not named are traced in the named colors instead of being refused; code-drawn tests in `crates/trace`. - **Image Trace, Keep Colors:** `"keepColors": ["#rrggbb", …]` traces only the palette colors near a listed color (Abutting: each with the paths the trace of every color gives it; Overlapping: not reaching under the colors left out), in Black and White, Grayscale and Color; code-drawn tests in `crates/trace` and `crates/engine`. - **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; an image that isn't turned or skewed, isn't CMYK and can be read at its recorded size is cut from its own pixels when the crop edges fall on its pixel grid, copied as they are without resampling; any other image is drawn, #5534; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). A single selected mirror repeat shows a prominent axis with a center move handle and two rotation handles. Drag the line or center to change spacing, or an end to change angle; Shift snaps rotation to 45°. The original artwork stays in place, the reflected copy previews live, Escape cancels and each drag takes one undo step through `object.repeat.options`. Controls follow zoom/view rotation and hide with edges or the bounding box. Engine and headless canvas regressions passed for gestures, history and visibility; native move, rotation and Escape screenshots were reviewed. [Public mirror-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-mirror-repeat-options.html). A single selected grid repeat shows a handle below the grid for rows, one right of it for columns and one past its bottom-right corner for both, and sliders between its first two columns and rows for the horizontal and vertical spacing; the source keeps its size, each drag is one undo step and Escape cancels it ([public grid-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html), #2691). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). Choosing the Blend tool again while it is active (its toolbar button, W or `tool.select`) finishes the blend: the object clicked first is dropped and the next click no longer adds to the blend just made, so the click after it starts a new blend ([Blending objects](https://web.archive.org/web/20241217212108/https://helpx.adobe.com/illustrator/using/blending-objects.html)). From 85b3740cca5446be1958565a4e01374f6ef6f971 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 10:06:33 -0800 Subject: [PATCH 77/92] Effects: a fill's or stroke's own Transform effect leaves the effects listed after it unscaled Effects apply in the order they are listed, so an effect listed after a Transform effect applies to the art the Transform effect made, at its own values, as an object's later effects do after the object's Transform effect. With Scale Strokes & Effects on, a fill's or stroke's own Transform effect scaled the effects listed after it in that fill's or stroke's list by the mean scale: a 300% Transform turned a 5 pt Offset Path after it into a 15 pt one, and a Drop Shadow after it took three times its offset. With Transform Patterns or Scale Strokes & Effects on, an SVG filter after it also had the origin of its user space moved. - The effects after such a Transform effect now keep their values, on the transformed art and on each copy, on every fill and stroke a live-effect plug-in paints the piece with, and when the copies are counted against the effect budget. - With Transform Objects off, the effects before it keep their values too. - The Transform params text, docs/target-app-parity.md and docs/gaps.md describe the rule, and drop the note that listed an object's later effects keeping their distances as a shortfall. --- crates/effects/src/catalog.rs | 2 +- crates/effects/src/reshape.rs | 70 +++++++++++++----- crates/effects/src/tests/rasterfollow.rs | 92 ++++++++++++++++++++++-- crates/effects/src/tests/transformfx.rs | 6 +- crates/engine/src/tests/plugins.rs | 30 ++++++++ docs/gaps.md | 4 +- docs/target-app-parity.md | 2 +- 7 files changed, 179 insertions(+), 27 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 1dfe06e6aa..7730dea316 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -267,7 +267,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> { "distort.transform", "Transform…", DT, - "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once; on, without copies or random, the raster effects listed before this effect (Rasterize and SVG filters excepted) transform with the art whatever strokes says: their sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales, while the angles and light directions of pixel filters stay as they are), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", + "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once; on, without copies or random, the raster effects listed before this effect (Rasterize and SVG filters excepted) transform with the art whatever strokes says: their sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales, while the angles and light directions of pixel filters stay as they are), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power; on a fill or stroke, the effects listed after this effect in that fill's or stroke's own list keep their distances and apply to the transformed art, and with objects off so do the effects before it)}", json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}), ), g( diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 58f083ef68..8822548c29 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -944,7 +944,7 @@ impl Step<'_> { // The copies of a path replace it by a group of them, the path included. let copied = |leaf: &Node| leaf.children().map_or(0, Vec::len); if options.reach_appearance() { - let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room); + let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, None); *used = used.minus(before).plus(made); if moved { // With Transform Objects off the path stays, and its mask takes the @@ -960,7 +960,7 @@ impl Step<'_> { } if separate { let scaling = vectorcraft_doc::Scaling { keep_fit_when_flat: true, ..Default::default() }; - let made = copy_leaf(leaf, &path, m, copies, scaling, room); + let made = copy_leaf(leaf, &path, m, copies, scaling, room, None); *used = used.minus(before).plus(made); moved_ids.insert(leaf.id); short |= counts(leaf.id) && copied(leaf) <= copies; @@ -1245,8 +1245,9 @@ fn copy_scaling(options: crate::distort::TransformOptions) -> vectorcraft_doc::S /// and the distances of the effects on its fills and strokes scale by `m`'s mean scale, and its /// pattern paints transform by `m`. With copies, the original keeps them and copy `k` takes them /// `k` times, each copy an object of its own ([`copy_leaf`], within `budget`). With Transform -/// Objects off the geometry stays and the options apply once. Returns what the leaf costs after, -/// and `false` when the path kept its shape because the moved path would pass `budget`. +/// Objects off the geometry stays and the options apply once. The effects `kept` names keep their +/// values on the leaf and on each copy ([`keep_listed`]). Returns what the leaf costs after, and +/// `false` when the path kept its shape because the moved path would pass `budget`. fn transform_leaf( leaf: &mut Node, path: &PathData, @@ -1254,6 +1255,7 @@ fn transform_leaf( copies: usize, options: crate::distort::TransformOptions, budget: Budget, + kept: Kept, ) -> (Budget, bool) { let scaling = copy_scaling(options); if !options.objects { @@ -1266,6 +1268,7 @@ fn transform_leaf( if options.patterns { vectorcraft_doc::pattern::transform_pattern_paints(leaf, m); } + keep_listed(leaf, kept); return (Budget::of(path), true); } if copies == 0 { @@ -1275,9 +1278,10 @@ fn transform_leaf( return (Budget::of(path), false); } leaf.transform(m, scaling); + keep_listed(leaf, kept); return (Budget::of(&moved), true); } - (copy_leaf(leaf, path, m, copies, scaling, budget), true) + (copy_leaf(leaf, path, m, copies, scaling, budget, kept), true) } /// Does a Transform effect's copies of `art` copy it whole ([`copy_art`])? They do when `art` is a @@ -1423,9 +1427,10 @@ fn copy_art(art: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc:: /// moves with it) rather than spanning every copy, and its strokes and patterns can differ from the /// others'. The copies stop where [`crate::distort::transform_within`]'s would, before they pass /// `budget` with what each copy's own geometry effects make ([`effects_cost`]), and before one's -/// strokes would scale past [`MAX_STROKE_SCALE`]. Returns what the copies cost, the original -/// included. -fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget) -> Budget { +/// strokes would scale past [`MAX_STROKE_SCALE`]. The effects `kept` names keep their values on +/// each copy, before its cost is counted ([`keep_listed`]). Returns what the copies cost, the +/// original included. +fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget, kept: Kept) -> Budget { let mut made = Budget::of(path).plus(effects_cost(leaf, path)); let mut placements: Vec<Arc<Node>> = vec![Arc::new(leaf.clone())]; let mut xf = Affine::IDENTITY; @@ -1438,6 +1443,7 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling } let mut copy = leaf.clone(); copy.transform(xf, scaling); + keep_listed(&mut copy, kept); // Each copy's own geometry effects make a different amount, as the copy and its effects scale. let mut cost = Budget::of(&moved).plus(effects_cost(©, &moved)); // Each copy carries the art of its linked masks, moved with it. @@ -1584,9 +1590,12 @@ fn item_makes_piece(item: &AppearanceItem) -> bool { /// effects. In paint order, each such fill or stroke is a path of its own: the leaf's geometry after /// the geometry effects of that fill or stroke before its first such Transform effect, transformed /// as [`transform_leaf`] transforms an object with that effect's map, copies and options, so the -/// stroke's weight and dashes, the distances of its remaining effects and its pattern follow the -/// map. The reference box is that geometry's bounds and the seed is the leaf's id, as the renderer -/// draws a fill's or stroke's own effects. The geometry effects after the Transform effect then +/// stroke's weight and dashes, the distances of the effects before the Transform effect and its +/// pattern follow the map, and the raster effects before it that follow it take their followed +/// values ([`follow_piece`]). The effects after it keep their values and apply to what it made +/// ([`keep_listed`]), and with Transform Objects off so do the effects before it. The reference box +/// is that geometry's bounds and the seed is the leaf's id, as the renderer draws a fill's or +/// stroke's own effects. The geometry effects after the Transform effect then /// apply to the transformed path and its copies as one piece ([`transform_piece`]), and its other /// effects stay on each of them. The other fills and strokes stay together on paths of the leaf's /// geometry between them, with their own effects. `max` is the budget of the whole art and `used` @@ -1699,13 +1708,15 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt let map = crate::distort::transform_affine(tb, ¶ms, ctx.seed); let mut it = item.clone(); let rest: Vec<Effect> = kept_before.iter().chain(&fx[at + 1..]).cloned().collect(); - *it.effects_mut() = rest; + *it.effects_mut() = rest.clone(); let mut p = piece(vec![it]); if carry.moves(&p.appearance) { carry.apply(&mut p.appearance); } set_geometry(&mut p, g.clone(), rule); - let (made, moved) = transform_leaf(&mut p, &g, map, copies, options, room); + let after = kept_before.len(); + let keep = |k: usize| k >= after || !options.objects; + let (made, moved) = transform_leaf(&mut p, &g, map, copies, options, room, Some((&rest, &keep))); *used = used.plus(made); let from = crate::raster::held_back(before); if moved && !held_later { @@ -1728,8 +1739,10 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt /// transformed piece of [`transform_item_leaf`] and its copies) to all of `p`'s paths as one piece: /// each effect takes the bounds of all the paths as its reference box and `seed`, as an object's /// own effects reshape all its paths ([`reshape_leaves_carrying`]). Each path applies its own copy of -/// the effect, whose distances its Scale Strokes & Effects scaled. A Transform effect with -/// Transform Patterns or Scale Strokes & Effects on transforms each path as [`transform_leaf`] does. +/// the effect, at the effect's own distances. A Transform effect with Transform Patterns or Scale +/// Strokes & Effects on transforms each path as [`transform_leaf`] does, and leaves the values of +/// the effects after it, and with Transform Objects off those before it, as they are +/// ([`keep_listed`]). /// A live-effect plug-in that paints what it returns ([`painted_plugin`]) makes each path one path /// for each piece it returns ([`paint_piece`]). The raster effects before a Transform effect /// follow it ([`follow_piece`]), except the first `held` effects, which a Transform effect before @@ -1781,7 +1794,8 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), h let (_, copies) = crate::distort::transform_map(bounds, ¶ms, seed); let options = crate::distort::TransformOptions::of(¶ms); let map = crate::distort::transform_affine(bounds, ¶ms, seed); - let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room); + let keep = |k: usize| k > j || (k < j && !options.objects); + let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, Some((item.effects(), &keep))); *used = used.minus(before).plus(made); if moved { follow_piece(leaf, was, from, &e); @@ -1815,6 +1829,30 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), h }); } +/// The effects a Transform effect in a fill's or stroke's own list leaves as they were +/// ([`keep_listed`]): the list as it was before the Transform effect was applied, and which of its +/// indices keep their values. `None` for an object's own Transform effect. +type Kept<'a> = Option<(&'a [Effect], &'a dyn Fn(usize) -> bool)>; + +/// Put back on the fills and strokes of each path under `p` the effect `kept`'s list holds at each +/// index it takes, in place of what a Transform effect in their own list scaled or moved with its +/// options ([`transform_leaf`]). The effects after the Transform effect apply to the art it made, +/// and with Transform Objects off the effects before it keep their values too. A piece's fills and +/// strokes share one list: a live-effect plug-in that paints what it returns gives each paint a copy +/// of the fill or stroke ([`vectorcraft_plugins::painted::items`]). +fn keep_listed(p: &mut Node, kept: Kept) { + let Some((was, keep)) = kept else { return }; + for_each_leaf(p, &mut |leaf| { + for item in &mut leaf.appearance.items { + for (k, (e, old)) in item.effects_mut().iter_mut().zip(was).enumerate() { + if keep(k) { + e.clone_from(old); + } + } + } + }); +} + /// Make the raster effects of each of `leaf`'s fills and strokes from index `from` on follow /// Transform effect `t`, listed after them, once `t` has moved the leaf /// ([`crate::raster::follow_one`]). The fills and strokes are copies of one fill or stroke (those a diff --git a/crates/effects/src/tests/rasterfollow.rs b/crates/effects/src/tests/rasterfollow.rs index 875d916940..c674cf5041 100644 --- a/crates/effects/src/tests/rasterfollow.rs +++ b/crates/effects/src/tests/rasterfollow.rs @@ -198,16 +198,16 @@ fn a_fills_shadow_follows_the_fills_own_transform() { } /// A Transform effect after a fill's own Transform effect with Scale Strokes & Effects on turns the -/// shadow between them, which the first one scaled; a Transform effect that moves at random holds -/// the shadow before it back from the Transform effects after it. +/// shadow between them, which the first one leaves as it is; a Transform effect that moves at random +/// holds the shadow before it back from the Transform effects after it. #[test] -fn a_fills_later_transform_turns_the_shadow_its_options_scaled() { +fn a_fills_later_transform_turns_the_shadow_after_its_first() { let n = fill_square( 12, vec![transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})), shadow(10.0, 0.0, 0.0), transform(json!({"rotate": 90}))], ); let art = crate::reshape(None, &n, None).unwrap(); - assert!(near(&shadows_in(&art), &[(0.0, -20.0)]), "{:?}", shadows_in(&art)); + assert!(near(&shadows_in(&art), &[(0.0, -10.0)]), "{:?}", shadows_in(&art)); let held = fill_square( 13, vec![ @@ -221,6 +221,90 @@ fn a_fills_later_transform_turns_the_shadow_its_options_scaled() { assert!(drawn.len() == 1 && drawn[0].0 > 0.0 && drawn[0].1.abs() < 1e-9, "{drawn:?}"); } +/// A fill's own Transform effect with Transform Patterns or Scale Strokes & Effects on leaves the +/// fill's raster effects after it as they are: they paint around the transformed art, its copies +/// included. With Transform Objects off it leaves the raster effects before it as they are too. +/// Drawn, expanded and baked alike. +#[test] +fn a_fills_own_transform_leaves_the_shadow_after_it() { + let cases = [ + vec![transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})), shadow(10.0, 0.0, 0.0)], + vec![transform(json!({"scaleH": 200, "scaleV": 200, "patterns": true, "strokes": true})), shadow(10.0, 0.0, 0.0)], + vec![transform(json!({"scaleH": 200, "scaleV": 200, "moveH": 120, "copies": 1, "strokes": true})), shadow(10.0, 0.0, 0.0)], + vec![transform(json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true})), shadow(10.0, 0.0, 0.0)], + vec![shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true}))], + // A later Transform effect with an option leaves the shadow after it as well. + vec![ + transform(json!({"scaleH": 200, "scaleV": 200, "patterns": true})), + transform(json!({"scaleH": 150, "scaleV": 150, "strokes": true})), + shadow(10.0, 0.0, 0.0), + ], + ]; + for effects in cases { + let n = fill_square(18, effects.clone()); + let art = crate::reshape(None, &n, None).unwrap(); + let drawn = shadows_in(&art); + assert!(!drawn.is_empty() && near(&drawn, &vec![(10.0, 0.0); drawn.len()]), "{effects:?}: drawn {drawn:?}"); + let expanded = shadows_in(&expand_outlined(None, &n, None).unwrap()); + assert!(!expanded.is_empty() && near(&expanded, &vec![(10.0, 0.0); expanded.len()]), "{effects:?}: expanded {expanded:?}"); + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, n).unwrap(); + let baked = bake_document(&d).unwrap(); + let b = shadows_in(baked.node(NodeId(18)).unwrap()); + assert!(!b.is_empty() && near(&b, &vec![(10.0, 0.0); b.len()]), "{effects:?}: baked {b:?}"); + } +} + +/// A geometry effect after a fill's own Transform effect with Scale Strokes & Effects on reshapes +/// the transformed art by its own distance, as it does after an object's Transform effect. +#[test] +fn a_fills_own_transform_leaves_the_offset_after_it() { + let offset = fx("path.offsetPath", json!({"offset": 5})); + let scaled = transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})); + let n = fill_square(19, vec![scaled.clone(), offset.clone()]); + let on_fill = crate::reshape(None, &n, None).unwrap(); + let on_object = crate::reshape(None, &square(20, vec![scaled, offset]), None).unwrap(); + let (fill, object) = (on_fill.geometric_bounds().unwrap(), on_object.geometric_bounds().unwrap()); + // The 50 pt square scaled to 100 pt about its center, then offset by 5 pt on each side. + assert!((fill.width() - 110.0).abs() < 1e-6 && (fill.height() - 110.0).abs() < 1e-6, "{fill:?}"); + assert!((fill.width() - object.width()).abs() < 1e-6 && (fill.x0 - object.x0).abs() < 1e-6, "{fill:?} {object:?}"); + let expanded = expand_outlined(None, &n, None).unwrap().geometric_bounds().unwrap(); + assert!((expanded.width() - 110.0).abs() < 1e-6 && (expanded.x0 - fill.x0).abs() < 1e-6, "expanded {expanded:?}"); +} + +/// The SVG filters on `n` and the nodes under it, from the object's effects and from each fill's +/// and stroke's. +fn filters_in(n: &Node) -> Vec<Effect> { + let mut out = vec![]; + n.walk(&mut |m| { + let lists = std::iter::once(&m.appearance.effects).chain(m.appearance.items.iter().map(|i| i.effects())); + out.extend(lists.flatten().filter(|e| e.id == crate::SVG_FILTER).cloned()); + }); + out +} + +/// An SVG filter after a fill's own Transform effect with Scale Strokes & Effects on keeps its +/// scale and the origin of its user space, as it does after the same Transform effect without +/// options and after an object's own Transform effect. +#[test] +fn a_fills_own_transform_leaves_the_svg_filter_after_it() { + let filter = fx(crate::SVG_FILTER, json!({"scale": 1.0, "origin": [10.0, 10.0]})); + for params in [ + json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "strokes": true}), + json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "patterns": true}), + json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "copies": 1, "strokes": true}), + json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true}), + ] { + let art = crate::reshape(None, &fill_square(21, vec![transform(params.clone()), filter.clone()]), None).unwrap(); + let drawn = filters_in(&art); + assert!(!drawn.is_empty() && drawn.iter().all(|e| *e == filter), "{params}: {drawn:?}"); + } + let object = square(22, vec![transform(json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "strokes": true})), filter.clone()]); + let art = crate::reshape(None, &object, None).unwrap(); + assert!(!filters_in(&art).is_empty() && filters_in(&art).iter().all(|e| *e == filter), "{:?}", filters_in(&art)); +} + /// The 3D effects keep the shadow before a Transform effect that shapes the art they project, the /// object's and its fill's 3D effect alike. #[test] diff --git a/crates/effects/src/tests/transformfx.rs b/crates/effects/src/tests/transformfx.rs index de69116b79..1046f3c437 100644 --- a/crates/effects/src/tests/transformfx.rs +++ b/crates/effects/src/tests/transformfx.rs @@ -222,7 +222,7 @@ fn each_copy_of_a_strokes_own_transform_scales_its_weight_once_more() { } #[test] -fn a_fills_own_transform_with_scale_strokes_and_effects_scales_its_later_effects() { +fn a_fills_own_transform_with_scale_strokes_and_effects_leaves_its_later_effects() { let mut n = stroke_with_effect(json!({})); if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { f.effects.push(transform(json!({"scaleH": 300, "scaleV": 300, "strokes": true}))); @@ -231,10 +231,10 @@ fn a_fills_own_transform_with_scale_strokes_and_effects_scales_its_later_effects let art = reshape(None, &n, None).unwrap(); let fill = leaves(&art).into_iter().find(|p| p.appearance.stroke().is_none()).unwrap(); let AppearanceItem::Fill(f) = &fill.appearance.items[0] else { panic!("not a fill") }; - // The Offset Path, scaled from 5 to 15, applies to the transformed fill's path. + // The Offset Path applies to the transformed fill's path at its own 5 pt. assert!(f.effects.is_empty(), "{:?}", f.effects); let w = fill.geometric_bounds().unwrap().width(); - assert!(close(w, 330.0), "{w}"); + assert!(close(w, 310.0), "{w}"); } #[test] diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index be4193a812..bb02ead656 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -476,6 +476,36 @@ fn a_shadow_on_each_fill_a_painted_effect_returns_follows_the_transform_after_it assert_eq!(far, near, "both shadows at 20 pt"); } +/// A Transform effect with Scale Strokes & Effects on after a painted effect on a fill leaves the +/// Drop Shadow after it as it is on every paint the piece is drawn with. +#[test] +fn a_transform_after_a_painted_fill_effect_leaves_the_later_shadow_on_every_paint() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let two = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00"), solid("#0000ff")]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.twopaints", true), &two)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.twopaints", "item": fill})); + let mut n = node(&s, id); + let later = [ + vectorcraft_doc::Effect { id: "distort.transform".into(), params: json!({"scaleH": 200, "scaleV": 200, "strokes": true}), visible: true }, + vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: json!({"x": 10.0, "y": 0.0, "blur": 0.0}), visible: true }, + ]; + n.appearance.items[fill].effects_mut().extend(later); + let art = vectorcraft_render::effects::reshape(Some(&s.doc().unwrap().doc), &n, None).unwrap(); + let mut offsets = vec![]; + art.walk(&mut |m| { + for item in &m.appearance.items { + for r in vectorcraft_render::effects::raster_effects(item.effects()) { + if let vectorcraft_render::effects::RasterFx::DropShadow { dx, dy, .. } = r { + offsets.push((dx, dy)); + } + } + } + }); + assert_eq!(offsets, [(10.0, 0.0), (10.0, 0.0)], "one shadow on each of the piece's two fills"); +} + /// A result refused for a swatch the document doesn't have stays cached: once the document has the /// swatch, the next check of that result clears the error. #[test] diff --git a/docs/gaps.md b/docs/gaps.md index 09c31c8ae8..243717a15b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -357,8 +357,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Patterns and Scale Strokes & Effects: off, Transform Objects leaves the geometry in place; the other two scale stroke weights, dashes and the fills' and strokes' effect distances and move pattern tiles with the object, copy `k` by the transformation `k` times. Canvas, export and Expand Appearance agree. A Transform effect in a fill's or - stroke's own effect list applies the two options to that fill or stroke and its later effects in the same way. - Not yet: object effects after it keep their distances. + stroke's own effect list applies the two options to that fill or stroke; the effects listed after it keep their + distances and apply to the transformed art, as an object's later effects do. - **Done (#937, reproduced case):** Shape Builder regions on art of more than 4,096 segments (traced or type outlines): the planar map no longer gives up there, so lines cut such art instead of the tool offering whole shapes (the planar map's edge-source and face lookups were quadratic in the segments, now indexed, and the cap is 65,536; Divide with open paths shares it). The edge-source index files a long segment under the grid cells along it, so a diagonal hatch (every line's bounds hold most points) no longer costs a scan of the hatch per edge. The grid holds a budget of references (64 per item plus four rows' or columns' worth), the items past it scanned, so coincident segments can't make it quadratic in memory. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e9c492cbe9..e9972424af 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -592,7 +592,7 @@ since 2026-10-01. - **`.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. +- **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 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`). The effects listed after it in that fill's or stroke's list keep their own distances and apply to the transformed art and to each copy, as an object's later effects do after the object's Transform effect: a Drop Shadow after it keeps its offset, an Offset Path after it offsets by its own distance, and an SVG filter after it keeps its scale and the origin of its user space. With Transform Objects off, the effects before it keep their distances too. Before, Scale Strokes & Effects scaled a fill's or stroke's later effects by the mean scale, so existing documents with such effects draw differently. Effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)); how the two options treat the effects after a fill's or stroke's own Transform effect is VectorCraft's own rule, following that order (effects `tests::rasterfollow::a_fills_own_transform_leaves_the_shadow_after_it`, `tests::rasterfollow::a_fills_own_transform_leaves_the_offset_after_it`, `tests::rasterfollow::a_fills_own_transform_leaves_the_svg_filter_after_it`, `tests::transformfx::a_fills_own_transform_with_scale_strokes_and_effects_leaves_its_later_effects`; engine `plugins::a_transform_after_a_painted_fill_effect_leaves_the_later_shadow_on_every_paint`). 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. - **Raster effects before a Transform effect:** effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). A Drop Shadow, glow, blur, Feather or pixel filter listed before a Transform effect in the same list (an object's effects, or a fill's or stroke's own) now follows that Transform effect: its sizes scale by the mean scale, and a Drop Shadow's offset turns, reflects and scales with the art. VectorCraft applies this rule with Scale Strokes & Effects on or off. Before, such an effect was drawn at its own distances around the transformed art. The canvas, export, Expand Appearance, Object › Expand, Outline Stroke and the 3D effects use the same values (effects `tests::rasterfollow`; render `a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art`; engine `objexpand::a_shadow_before_a_transform_expands_as_the_canvas_draws_it`, `outlinestroke::a_shadow_before_a_transform_outlines_as_the_canvas_draws_it`). Not yet: a Transform effect that makes copies or moves at random leaves the raster effects before it as they are; a Rasterize effect before a Transform effect keeps its pixel grid and room around the art; the angles and light directions of pixel filters do not turn or reflect with the art. An object's or group's Transform effect changes the effects of its fills, strokes and members only through Scale Strokes & Effects, as before. Scores and hours unchanged. - **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim. - **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged. From be50e5328b6a91ac004ede7ecc6c3ce84f03659e Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 09:28:32 -0800 Subject: [PATCH 78/92] Transform: a bevel stops at the corners of the segments it joins with Use Preview Bounds A bevel join, and a miter join whose miter limit bevels a corner, end the stroke at the outer corners of the two segments they join. The visual box measured such a corner as if the stroke reached half its weight round it: a 40 x 100 pt triangle with a 20 pt stroke and a miter limit of 2 measured 10 pt above its tip, where the stroke paints about 1.96 pt above it. Use Preview Bounds, and the alignment and sizing that use it, took that box. - With bevel and miter joins, closed paths and open paths with round or projecting caps are now measured along their segments, as butt-capped open paths already were, with the miter tips the limit keeps and the caps added. Round joins still reach half the weight round each corner, the round caps of dashes, which can end at a corner, reach half the weight all round, and the projecting caps of dashes reach the diagonal of their square. The visual box of such a stroke can be smaller than before and is never larger. A path with no width, such as a rectangle sized to 0 pt wide, turns back at its ends, where the join bevels, so its box no longer reaches past them. - object.setBounds with Use Preview Bounds measures an object as drawn when a beveled or mitered corner, or a butt cap, is on a segment at a slant. Before, a closed path with miter spikes the limit keeps missed the size typed when the width and height were out of proportion. - A click past a bevel or a butt cap, by more than the click tolerance, no longer selects the stroke. Strokes with a width profile or with dashes that have round or projecting caps, and the ends of curves that bend more tightly than half the weight, keep the round reach. - Paths measured along their segments skip the segments that cannot reach past the box measured so far, which starts with the path's own bounds. --- crates/doc/src/reach.rs | 277 +++++++++++++++++------ crates/effects/src/stroke/tests_reach.rs | 29 +++ crates/engine/src/cmd/object/bounds.rs | 8 +- crates/engine/src/tests/previewbounds.rs | 30 ++- docs/target-app-parity.md | 1 + 5 files changed, 271 insertions(+), 74 deletions(-) diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs index 8279507e01..7f9b6a1766 100644 --- a/crates/doc/src/reach.rs +++ b/crates/doc/src/reach.rs @@ -1,6 +1,7 @@ //! Where a path's strokes paint, from the path's geometry without building their outlines: the //! visual bounds and the clicks of a stroke take in its alignment, its width profile, miter spikes -//! up to the miter limit, projecting caps and arrowheads. Butt caps end at the path's end points. +//! up to the miter limit, projecting caps and arrowheads. Butt caps end at the path's end points, +//! and a bevel ends at the corners of the two segments it joins. use vectorcraft_geom::bez::{CORNER_COS, has_length, kink, segments, subpaths, tangent, unit}; use vectorcraft_geom::hit::fill_contains; @@ -39,10 +40,20 @@ fn joints(segs: &[PathSeg], closed: bool) -> impl Iterator<Item = (Point, Vec2, /// path: its tip and the corner's outer offset points, or `None` when the path doesn't turn there /// or the miter limit bevels it. fn miter(at: Point, t_in: Vec2, t_out: Vec2, side: f64, limit: f64) -> Option<[Point; 3]> { - let cos = t_in.dot(t_out); // cos(half the turn) is sin(half the angle between the segments): the miter is side / it. - let half = ((1.0 + cos) / 2.0).max(0.0).sqrt(); - if cos >= CORNER_COS || half * limit < 1.0 { + let half = ((1.0 + t_in.dot(t_out)) / 2.0).max(0.0).sqrt(); + if half * limit < 1.0 { + return None; + } + let ([a, b], out) = bevel(at, t_in, t_out, side)?; + Some([a, at + out * (side / half), b]) +} + +/// The bevel at a corner with tangents `t_in`, `t_out` of a stroke reaching `side` from its path: +/// the outer corners of the two segments' strokes and the unit direction out of the turn, or +/// `None` when the path doesn't turn there. +fn bevel(at: Point, t_in: Vec2, t_out: Vec2, side: f64) -> Option<([Point; 2], Vec2)> { + if t_in.dot(t_out) >= CORNER_COS { return None; } let out = unit(t_in - t_out); @@ -51,7 +62,7 @@ fn miter(at: Point, t_in: Vec2, t_out: Vec2, side: f64, limit: f64) -> Option<[P let n = Vec2::new(-t.y, t.x); if n.dot(out) >= 0.0 { n } else { -n } }; - Some([at + outer(t_in) * side, at + out * (side / half), at + outer(t_out) * side]) + Some(([at + outer(t_in) * side, at + outer(t_out) * side], out)) } /// The heads' ends of `bp`: the start of its first subpath and the end of its last, when open (as @@ -187,26 +198,36 @@ fn in_convex(pts: &[Point], p: Point, tol: f64) -> bool { } impl StrokeLayer { - /// Does bounding this stroke need its path's shape: miter spikes, projecting caps, arrowheads, - /// an alignment that depends on whether the path is closed, or butt caps, which stop at the - /// ends of an open path? Else it reaches evenly round it. + /// Does bounding this stroke need its path's shape: miter spikes, bevels, projecting caps, + /// arrowheads, an alignment that depends on whether the path is closed, or butt caps, which + /// stop at the ends of an open path? Else it reaches evenly round it. fn needs_shape(&self) -> bool { - self.join == LineJoin::Miter + self.join != LineJoin::Round || self.cap != LineCap::Round || self.start_arrow.is_some() || self.end_arrow.is_some() || self.align != StrokeAlign::Center } - /// The box this stroke paints along `segs`, the segments with length of an open subpath, when - /// its caps are butt caps: each segment's [`swept_box`] and, with round joins, the arc each - /// joint between segments paints ([`round_join_box`]), a shallow one included. - /// `None` when `segs` is empty. Butt dashes stop inside the same box. The heads and miter tips - /// are measured apart. A segment in the middle is measured only when its own box grown by - /// `side`, which holds everything it paints, is not inside the box so far. - fn butt_body(&self, segs: &[PathSeg], side: f64) -> Option<Rect> { + /// Does this stroke stop at the corners of its segments along a subpath that is `closed` or + /// open, where its caps are butt caps on an open subpath or its joins are bevels or miters? + /// Dashes with round or projecting caps can end at a corner, so with them only butt caps stop. + fn stops_at_corners(&self, closed: bool) -> bool { + let capped_dashes = self.cap != LineCap::Butt && self.dash.as_ref().is_some_and(Dash::is_dashed); + (self.cap == LineCap::Butt && !closed) || (self.join != LineJoin::Round && !capped_dashes) + } + + /// The box this stroke's body paints along `segs`, the segments with length of a subpath, short + /// of its caps, joined with `within`: each segment's [`swept_box`] and, with round joins on an + /// open subpath, the arc each joint between segments paints ([`round_join_box`]), a shallow one + /// included. A bevel stays inside the boxes of the segments it joins. `None` when `segs` is + /// empty. Butt dashes stop inside the same box. The caps other than butt ones, the heads and + /// miter tips are measured apart. A segment in the middle is measured only when its own box + /// grown by `side`, which holds everything it paints, is not inside the box so far, which + /// starts as `within` and the first and last segments' boxes. + fn body_along(&self, segs: &[PathSeg], side: f64, within: Rect) -> Option<Rect> { let (first, last) = (segs.first()?, segs.last()?); - let ends = swept_box(first, side, 3).union(swept_box(last, side, 3)); + let ends = swept_box(first, side, 3).union(swept_box(last, side, 3)).union(within); let body = segs.iter().skip(1).take(segs.len().saturating_sub(2)).fold(ends, |b, s| { if b.contains_rect(ParamCurveExtrema::bounding_box(s).inflate(side, side)) { b } else { b.union(swept_box(s, side, 3)) } }); @@ -217,24 +238,28 @@ impl StrokeLayer { } /// The box this stroke paints along `bp` (geometric bounds `bounds`): an open subpath with butt - /// caps by [`Self::butt_body`], every other subpath by the box of its segments grown by its - /// reach, with `tips` and, when `measure_tips`, the miter spikes the limit keeps, the corners of - /// projecting caps and the reach of the arrowheads round their end points. + /// caps, and every subpath when the joins are bevels or miters, by [`Self::body_along`] with its + /// caps, every other subpath by the box of its segments grown by its reach, with `tips` and, + /// when `measure_tips`, the miter spikes the limit keeps, the corners of projecting caps and the + /// reach of the arrowheads round their end points. fn reach_along(&self, bp: &BezPath, bounds: Rect, tips: Option<Rect>, measure_tips: bool) -> Rect { let els = bp.elements(); let subs = subpaths(bp); let mut out = tips.map_or(bounds, |t| bounds.union(t)); let mut side_max: f64 = 0.0; - // Every subpath reaches evenly unless one is open and butt-capped; only then is each - // subpath's own box needed. - let even_only = self.cap != LineCap::Butt || subs.iter().all(|s| s.1); - let mut even = even_only.then_some(bounds); // Dashes put a cap on every dash: projecting ones reach a corner's diagonal anywhere. let capped_dashes = self.cap == LineCap::Square && self.dash.as_ref().is_some_and(Dash::is_dashed); + // A butt cap stops at an open subpath's end, and a bevel, or a miter the limit bevels, at + // the two segments' corners, so such a subpath is measured along its segments. Every + // subpath reaches evenly unless one is measured that way; only then is each subpath's own + // box needed. + let even_only = !subs.iter().any(|s| self.stops_at_corners(s.1)); + let mut even = even_only.then_some(bounds); for (r, closed) in subs { let side = self.side_reach(closed); let butt = self.cap == LineCap::Butt && !closed; - if !butt { + let along = self.stops_at_corners(closed); + if !along { side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side }); } if side <= 0.0 { @@ -242,10 +267,10 @@ impl StrokeLayer { } let square = self.cap == LineCap::Square && !closed; let miters = measure_tips && self.join == LineJoin::Miter; - if !(butt || square || miters || !even_only) { + if !(along || square || miters || !even_only) { continue; } - let segs = drawn(&els[r]); + let segs = drawn(&els[r.clone()]); if miters { for (at, t_in, t_out) in joints(&segs, closed) { if let Some([_, tip, _]) = miter(at, t_in, t_out, side, self.miter_limit) { @@ -253,13 +278,24 @@ impl StrokeLayer { } } } - if butt { - if let Some(body) = self.butt_body(&segs, side) { - out = out.union(body); + if along { + // The path's bounds are in the box already, so only the segments that can reach + // past them are measured. + match self.body_along(&segs, side, out) { + Some(body) => out = out.union(body), + // A subpath with no length paints at most its caps' dot. + None if !butt => { + let dot = segments(&els[r]).iter().map(ParamCurveExtrema::bounding_box).reduce(|a, b| a.union(b)); + out = dot.map_or(out, |d| out.union(d.inflate(side, side))); + } + None => {} } - continue; - } - if !even_only { + if self.cap == LineCap::Round && !closed { + for (at, _) in ends(&segs).into_iter().flatten() { + out = out.union(Rect::from_center_size(at, (2.0 * side, 2.0 * side))); + } + } + } else if !even_only { even = vectorcraft_geom::union_opt(even, segs.iter().map(ParamCurveExtrema::bounding_box).reduce(|a, b| a.union(b))); } if square { @@ -279,7 +315,8 @@ impl StrokeLayer { /// The box this stroke paints along `bp`, whose geometric bounds are `bounds`: the bounds grown /// by the body's reach, with the tips of the miter spikes the limit keeps, the corners of /// projecting caps and the reach of the arrowheads round their end points. An open subpath - /// with butt caps does not reach past its ends ([`Self::butt_body`]). + /// with butt caps does not reach past its ends, and a bevel does not reach round its corner + /// ([`Self::body_along`]). pub fn bounds_on(&self, bp: &BezPath, bounds: Rect) -> Rect { self.reach_along(bp, bounds, None, true) } @@ -329,6 +366,9 @@ impl StrokeLayer { let segs = drawn(&els[r]); let (half, region) = self.body(closed); let full = half * self.profile_max() * dash_caps.unwrap_or(1.0); + // Past a butt cap or a bevel the stroke stops at the segments' corners. A width profile + // keeps the round reach at the ends. + let squared = self.profile.is_none() && self.stops_at_corners(closed); // Every segment near enough to reach `p`: with a width profile the wider side of a // farther segment can paint past the nearest one, so the nearest alone is not enough. let near = segs.iter().enumerate().filter_map(|(i, s)| { @@ -365,7 +405,8 @@ impl StrokeLayer { } }) }; - if d <= half * factor.max(0.0) + tol && on_side(region, d) { + if d <= half * factor.max(0.0) + tol && on_side(region, d) && (!squared || self.paints_at_end(&segs, closed, (i, nr.t), p, half, tol)) + { return true; } } @@ -393,6 +434,33 @@ impl StrokeLayer { false } + /// Does this stroke, where it stops at the corners of its segments ([`Self::stops_at_corners`]), + /// paint `p`, which is within `side` (and `tol`) of segment `i` of the subpath `segs` + /// (`closed`: a closed one) and nearest to it at `t`? It does when `p` is not past the end of + /// the segment that `t` is at, by more than `tol`, and past it when a round cap or a round join + /// paints there, or when `p` is within `tol` of the bevel's triangle. Projecting caps and + /// miter spikes are checked apart. A curve that bends more tightly than `side` ([`gentle`]) + /// counts as painting round its ends. + fn paints_at_end(&self, segs: &[PathSeg], closed: bool, (i, t): (usize, f64), p: Point, side: f64, tol: f64) -> bool { + let (n, at_start) = (segs.len(), t <= 1e-9); + let Some(seg) = segs.get(i) else { return true }; + if (!at_start && t < 1.0 - 1e-9) || !(matches!(seg, PathSeg::Line(_)) || gentle(seg, side)) { + return true; + } + let (at, out) = if at_start { (seg.start(), -tangent(seg, 0.0)) } else { (seg.end(), tangent(seg, 1.0)) }; + if (p - at).dot(out) <= tol { + return true; + } + // The segment on the other side of this end, `None` at the end of an open subpath. + let other = if at_start { (i > 0 || closed).then(|| (i + n - 1) % n) } else { (i + 1 < n || closed).then(|| (i + 1) % n) }; + let Some(other) = other.and_then(|o| segs.get(o)) else { return self.cap == LineCap::Round }; + if self.join == LineJoin::Round { + return true; + } + let (t_in, t_out) = if at_start { (tangent(other, 1.0), tangent(seg, 0.0)) } else { (tangent(seg, 1.0), tangent(other, 0.0)) }; + bevel(at, t_in, t_out, side).is_some_and(|([a, b], _)| in_convex(&[at, a, b], p, tol)) + } + /// Is `p` within `tol` of where the start (`end == false`) or end arrowhead at `at` (the path /// leaving along `out`) can paint? A head reaches [`crate::Arrowhead::reach`] weights from its tip and /// [`crate::Arrowhead::half_width`] either side of its line (4.6 and 2 for most). With @@ -426,7 +494,7 @@ impl Appearance { } /// [`Self::stroked_bounds`] with the box of each visible stroke that needs the path's shape - /// (miter spikes, projecting or butt caps, arrowheads or an alignment) given by `shaped`. + /// (miter spikes, bevels, projecting or butt caps, arrowheads or an alignment) given by `shaped`. pub fn stroked_bounds_by(&self, bounds: Rect, mut shaped: impl FnMut(&StrokeLayer) -> Rect) -> Rect { self.painted_strokes().fold(bounds, |acc, s| { if !s.needs_shape() { @@ -437,17 +505,19 @@ impl Appearance { }) } - /// Does this appearance have a visible butt-capped stroke? - pub fn has_butt_stroke(&self) -> bool { - self.painted_strokes().any(|s| s.cap == LineCap::Butt) + /// Does this appearance have a visible stroke that stops at the corners of its segments on an + /// open subpath or a closed one: butt caps, or bevel and miter joins? + pub fn has_cornered_stroke(&self) -> bool { + self.painted_strokes().any(|s| s.stops_at_corners(false)) } - /// Does a visible butt-capped stroke of this appearance run along an open subpath of `bp` with - /// a segment that starts or ends at a slant, not square to the axes, once placed by `a`? The - /// visual box's margin at the stroke's corners there depends on their direction, which a scale - /// out of proportion changes. - pub fn has_slanted_butt_stroke(&self, bp: &BezPath, a: vectorcraft_geom::Affine) -> bool { - if !self.has_butt_stroke() { + /// Does a visible stroke of this appearance that stops at the corners of its segments (butt + /// caps on an open subpath, bevel and miter joins) run along a subpath of `bp` where it does so, + /// with a segment that starts or ends at a slant, not square to the axes, once placed by `a`? + /// The visual box's margin at those corners, and at miter tips, depends on their direction, + /// which a scale out of proportion changes. + pub fn has_slanted_stroke_corners(&self, bp: &BezPath, a: vectorcraft_geom::Affine) -> bool { + if !self.has_cornered_stroke() { return false; } let slanted = |v: Vec2| { @@ -455,9 +525,10 @@ impl Appearance { (d.x * d.y).abs() > 1e-9 }; let els = bp.elements(); - subpaths(bp) - .into_iter() - .filter(|(_, closed)| !closed) + let subs = subpaths(bp); + let cornered = |closed: bool| self.painted_strokes().any(|s| s.stops_at_corners(closed)); + subs.into_iter() + .filter(|(_, closed)| cornered(*closed)) .any(|(r, _)| drawn(&els[r]).iter().any(|s| slanted(tangent(s, 0.0)) || slanted(tangent(s, 1.0)))) } @@ -468,28 +539,28 @@ impl Appearance { } } -/// How many symbol instances [`crate::Node::has_slanted_butt_stroke`] looks into for one object +/// How many symbol instances [`crate::Node::has_slanted_stroke_corners`] looks into for one object /// before it answers yes without looking further. pub const SLANT_INSTANCES: usize = 4096; impl crate::Node { - /// Does a visible butt-capped stroke of this object, of an object in it or of an object in a - /// symbol instance's art from `symbols`, placed by `a`, run along an open path with a segment - /// at a slant ([`Appearance::has_slanted_butt_stroke`])? Past [`SLANT_INSTANCES`] instances it - /// answers yes. - pub fn has_slanted_butt_stroke(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine) -> bool { - self.slanted_butt(symbols, a, &std::cell::Cell::new(SLANT_INSTANCES)) + /// Does a visible stroke of this object, of an object in it or of an object in a symbol + /// instance's art from `symbols`, placed by `a`, stop at the corners of a segment at a slant + /// ([`Appearance::has_slanted_stroke_corners`])? Past [`SLANT_INSTANCES`] instances it answers + /// yes. + pub fn has_slanted_stroke_corners(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine) -> bool { + self.slanted_corners(symbols, a, &std::cell::Cell::new(SLANT_INSTANCES)) } - /// [`Self::has_slanted_butt_stroke`] with `left` instances still to look into. - fn slanted_butt(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine, left: &std::cell::Cell<usize>) -> bool { + /// [`Self::has_slanted_stroke_corners`] with `left` instances still to look into. + fn slanted_corners(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine, left: &std::cell::Cell<usize>) -> bool { if let crate::NodeKind::SymbolInstance { symbol, xf } = &self.kind { let Some(l) = left.get().checked_sub(1) else { return true }; left.set(l); - return symbols.measure_art(symbol, |art| art.slanted_butt(symbols, a * *xf, left)).unwrap_or(false); + return symbols.measure_art(symbol, |art| art.slanted_corners(symbols, a * *xf, left)).unwrap_or(false); } - (self.appearance.has_butt_stroke() && self.stroke_path().is_some_and(|bp| self.appearance.has_slanted_butt_stroke(&bp, a))) - || self.children().is_some_and(|c| c.iter().any(|c| c.slanted_butt(symbols, a, left))) + (self.appearance.has_cornered_stroke() && self.stroke_path().is_some_and(|bp| self.appearance.has_slanted_stroke_corners(&bp, a))) + || self.children().is_some_and(|c| c.iter().any(|c| c.slanted_corners(symbols, a, left))) } } @@ -566,6 +637,68 @@ mod tests { assert_eq!(stroke(4.0, |s| s.align = StrokeAlign::Outside).bounds_on(&sq, b), b.inflate(4.0, 4.0)); } + #[test] + fn a_bevel_stops_at_the_corners_of_the_segments_it_joins() { + // A 40 × 100 pt triangle stroked 20 pt: a miter limit of 2 or a bevel join bevels its + // 22.6° tip at (20, 0), so the stroke reaches 10 · 20 / √10400 pt above it, not 10 pt. + let mut tri = BezPath::new(); + tri.move_to((0.0, 100.0)); + tri.line_to((20.0, 0.0)); + tri.line_to((40.0, 100.0)); + tri.close_path(); + let tb = tri.bounding_box(); + let (top, side) = (-200.0 / 10400.0_f64.sqrt(), 1000.0 / 10400.0_f64.sqrt()); + let limited = stroke(20.0, |s| s.miter_limit = 2.0); + let bevel = stroke(20.0, |s| { + s.join = LineJoin::Bevel; + s.cap = LineCap::Round; + }); + let b = limited.bounds_on(&tri, tb); + assert!((b.y0 - top).abs() < 1e-9, "{b:?}"); + // The limit keeps the base corners' miters, whose tips reach about 12.2 pt left and right. + assert!(b.x0 < -12.0 && b.x1 > 52.0, "{b:?}"); + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 20.0); + *ap.stroke_mut().unwrap() = bevel.clone(); + let b = ap.stroked_bounds(tb, || tri.clone()); + for (got, want) in [(b.x0, -side), (b.y0, top), (b.x1, 40.0 + side), (b.y1, 110.0)] { + assert!((got - want).abs() < 1e-9, "{b:?}"); + } + // Open, the same corner is beveled and the round caps reach the half weight round the ends. + let chevron = polyline(&[(0.0, 100.0), (20.0, 0.0), (40.0, 100.0)]); + let b = bevel.bounds_on(&chevron, chevron.bounding_box()); + for (got, want) in [(b.x0, -10.0), (b.y0, top), (b.x1, 50.0), (b.y1, 110.0)] { + assert!((got - want).abs() < 1e-9, "{b:?}"); + } + // A round join, and the round caps of dashes, which can end at the tip, reach round it. + let round = stroke(20.0, |s| s.join = LineJoin::Round); + assert!((round.bounds_on(&tri, tb).y0 + 10.0).abs() < 1e-9); + let mut dashed = bevel.clone(); + dashed.dash = Some(Dash { pattern: vec![7.0, 4.0], offset: 0.0, align_corners: false }); + assert!((dashed.bounds_on(&chevron, chevron.bounding_box()).y0 + 10.0).abs() < 1e-9); + } + + #[test] + fn a_click_past_a_bevel_or_a_butt_end_misses() { + // 5 pt above the triangle's tip is within the half weight of it, but past the bevel, which + // reaches about 1.96 pt above it. A round join paints there. + let mut tri = polyline(&[(0.0, 100.0), (20.0, 0.0), (40.0, 100.0)]); + tri.close_path(); + let (above, on) = (Point::new(20.0, -5.0), Point::new(20.0, -1.5)); + for join in [LineJoin::Bevel, LineJoin::Miter] { + let st = stroke(20.0, |s| (s.join, s.miter_limit) = (join, 2.0)); + assert!(!st.hits(&tri, FillRule::NonZero, above, 0.5), "{join:?}"); + assert!(st.hits(&tri, FillRule::NonZero, on, 0.0), "{join:?}"); + } + assert!(stroke(20.0, |s| s.join = LineJoin::Round).hits(&tri, FillRule::NonZero, above, 0.5)); + // 1.5 pt past a butt end of a 4 pt line misses, and a quarter point within it hits; a round + // cap paints past it. + let line = polyline(&[(0.0, 0.0), (0.0, 50.0)]); + let butt = stroke(4.0, |s| s.cap = LineCap::Butt); + assert!(!butt.hits(&line, FillRule::NonZero, Point::new(1.0, -1.5), 0.25)); + assert!(butt.hits(&line, FillRule::NonZero, Point::new(1.0, -0.2), 0.25)); + assert!(stroke(4.0, |s| s.cap = LineCap::Round).hits(&line, FillRule::NonZero, Point::new(1.0, -1.5), 0.25)); + } + /// An open path through `points`. fn polyline(points: &[(f64, f64)]) -> BezPath { let mut bp = BezPath::new(); @@ -691,15 +824,25 @@ mod tests { let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0); ap.stroke_mut().unwrap().cap = LineCap::Butt; let id = vectorcraft_geom::Affine::IDENTITY; - assert!(!ap.has_slanted_butt_stroke(&polyline(&[(0.0, 0.0), (0.0, 50.0), (30.0, 50.0)]), id)); + assert!(!ap.has_slanted_stroke_corners(&polyline(&[(0.0, 0.0), (0.0, 50.0), (30.0, 50.0)]), id)); // Square ends, slanted segments in the middle. let apex = polyline(&[(0.0, 50.0), (0.0, 40.0), (10.0, 0.0), (20.0, 40.0), (20.0, 50.0)]); - assert!(ap.has_slanted_butt_stroke(&apex, id)); - // A rotation slants a vertical line; a round cap is never slanted. + assert!(ap.has_slanted_stroke_corners(&apex, id)); + // A rotation slants a vertical line. let v = polyline(&[(0.0, 0.0), (0.0, 50.0)]); - assert!(ap.has_slanted_butt_stroke(&v, vectorcraft_geom::Affine::rotate(0.3))); + assert!(ap.has_slanted_stroke_corners(&v, vectorcraft_geom::Affine::rotate(0.3))); + // With round caps, the miter join still stops at the corners of the slanted segments, on a + // closed path too; round caps and round joins never do. ap.stroke_mut().unwrap().cap = LineCap::Round; - assert!(!ap.has_slanted_butt_stroke(&apex, id)); + assert!(ap.has_slanted_stroke_corners(&apex, id)); + let mut tri = polyline(&[(0.0, 100.0), (20.0, 0.0), (40.0, 100.0)]); + tri.close_path(); + ap.stroke_mut().unwrap().join = LineJoin::Bevel; + assert!(ap.has_slanted_stroke_corners(&tri, id)); + assert!(!ap.has_slanted_stroke_corners(&square(), id)); + ap.stroke_mut().unwrap().join = LineJoin::Round; + assert!(!ap.has_slanted_stroke_corners(&apex, id)); + assert!(!ap.has_slanted_stroke_corners(&tri, id)); } #[test] @@ -723,11 +866,11 @@ mod tests { } let top = Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: "S12".into(), xf: vectorcraft_geom::Affine::IDENTITY }); let symbols = d.symbol_boxes(); - assert!(top.has_slanted_butt_stroke(&symbols, vectorcraft_geom::Affine::IDENTITY)); + assert!(top.has_slanted_stroke_corners(&symbols, vectorcraft_geom::Affine::IDENTITY)); assert!(symbols.art_measured() <= SLANT_INSTANCES, "{}", symbols.art_measured()); // One instance of S1 holds three instances, all looked into. let one = Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: "S1".into(), xf: vectorcraft_geom::Affine::IDENTITY }); - assert!(!one.has_slanted_butt_stroke(&d.symbol_boxes(), vectorcraft_geom::Affine::IDENTITY)); + assert!(!one.has_slanted_stroke_corners(&d.symbol_boxes(), vectorcraft_geom::Affine::IDENTITY)); } #[test] diff --git a/crates/effects/src/stroke/tests_reach.rs b/crates/effects/src/stroke/tests_reach.rs index 29a48343ed..d38964ee01 100644 --- a/crates/effects/src/stroke/tests_reach.rs +++ b/crates/effects/src/stroke/tests_reach.rs @@ -78,6 +78,35 @@ fn a_butt_capped_open_path_is_measured_where_it_paints() { } } +#[test] +fn beveled_corners_are_measured_where_they_paint() { + let mut tip = BezPath::new(); + tip.move_to((0.0, 100.0)); + tip.line_to((20.0, 0.0)); + tip.line_to((40.0, 100.0)); + tip.close_path(); + let mut chevron = BezPath::new(); + chevron.move_to((0.0, 100.0)); + chevron.line_to((20.0, 0.0)); + chevron.line_to((40.0, 100.0)); + // A rectangle with no width turns back at its top and bottom. + let flat = vectorcraft_geom::shapes::rectangle(Rect::new(3.0, 100.0, 3.0, 150.0)).to_bezpath(); + let ps = paths(); + for (name, bp) in [("tip", &tip), ("chevron", &chevron), ("zigzag", &ps[1]), ("triangle", &ps[2]), ("flat", &flat)] { + for cap in [LineCap::Butt, LineCap::Round, LineCap::Square] { + for (join, limit) in [(LineJoin::Bevel, 10.0), (LineJoin::Miter, 1.0), (LineJoin::Miter, 2.0), (LineJoin::Miter, 10.0)] { + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 20.0); + (st.cap, st.join, st.miter_limit) = (cap, join, limit); + let painted = outline_region(&PathData::from_bezpath(bp), FillRule::NonZero, &st).to_bezpath().bounding_box(); + let bounds = st.bounds_on(bp, bp.bounding_box()); + for (got, want) in [(bounds.x0, painted.x0), (bounds.y0, painted.y0), (bounds.x1, painted.x1), (bounds.y1, painted.y1)] { + assert!((got - want).abs() < 0.05, "{name} {cap:?} {join:?} {limit}: measured {bounds:?}, paints {painted:?}"); + } + } + } + } +} + #[test] fn bounds_and_clicks_cover_what_every_stroke_paints() { let profiles = [None, Some(WidthProfile::lens()), Some(WidthProfile { points: vec![(0.0, 2.0, 0.5), (0.6, 0.8, 1.6), (1.0, 1.2, 0.3)] })]; diff --git a/crates/engine/src/cmd/object/bounds.rs b/crates/engine/src/cmd/object/bounds.rs index 3dc1c37dad..0a427e3ea0 100644 --- a/crates/engine/src/cmd/object/bounds.rs +++ b/crates/engine/src/cmd/object/bounds.rs @@ -133,16 +133,16 @@ fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64, anchor // the objects differ, the members of groups and the objects in symbols' art included, and with // strokes that scale, when the sizes are out of proportion. The objects are then measured one // by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects, - // objects with a butt-capped stroke along a segment at a slant, whose margins change with the - // segment's direction, and type measured by its glyphs, are measured as drawn after each scale - // tried instead ([`refit_drawn`]). + // objects with a stroke that stops at the corners of a segment at a slant (butt caps, bevel and + // miter joins), whose margins change with the segment's direction, and type measured by its + // glyphs, are measured as drawn after each scale tried instead ([`refit_drawn`]). let to_local = frame.to_doc().inverse(); let drawn = || { by_glyphs || ids .iter() .filter_map(|id| d.node(*id)) - .any(|n| effects::preview::draws_geometry(&symbols, n) || n.has_slanted_butt_stroke(&symbols, to_local)) + .any(|n| effects::preview::draws_geometry(&symbols, n) || n.has_slanted_stroke_corners(&symbols, to_local)) }; // Without Use Preview Bounds the box has no margins (`v`, `vs` and `g` are one box, glyph ink // included), so the scales above are exact and nothing is refitted. diff --git a/crates/engine/src/tests/previewbounds.rs b/crates/engine/src/tests/previewbounds.rs index 773b2b7842..c0ce01cb57 100644 --- a/crates/engine/src/tests/previewbounds.rs +++ b/crates/engine/src/tests/previewbounds.rs @@ -581,11 +581,13 @@ fn set_bounds_measures_paths_turned_together_square_to_their_box() { fn a_size_below_what_the_kept_strokes_of_several_paths_add_gives_them_no_width() { // With no width, the paths have a visual box 10 pt wide, the width of the thick stroke. A width // of 0 or more below 10 gives both paths no width, and the left edge of the visual box stays at - // -2. A negative width turns the paths over, as it does one path. + // -2. With no width each path turns back at its top and bottom, where the miter join bevels, + // so the visual box runs from y 100 to 150. A negative width turns the paths over, as it does + // one path. let mut wrong = vec![]; for (width, paths, visual) in [ - (8.0, [Rect::new(3.0, 100.0, 3.0, 150.0); 2], Rect::new(-2.0, 95.0, 8.0, 155.0)), - (0.0, [Rect::new(3.0, 100.0, 3.0, 150.0); 2], Rect::new(-2.0, 95.0, 8.0, 155.0)), + (8.0, [Rect::new(3.0, 100.0, 3.0, 150.0); 2], Rect::new(-2.0, 100.0, 8.0, 150.0)), + (0.0, [Rect::new(3.0, 100.0, 3.0, 150.0); 2], Rect::new(-2.0, 100.0, 8.0, 150.0)), (-37.0, [Rect::new(-44.0, 100.0, 0.0, 150.0), Rect::new(-44.0, 100.0, -13.2, 150.0)], Rect::new(-49.0, 95.0, 1.0, 155.0)), ] { let (mut s, ids) = thin_and_thick(); @@ -666,6 +668,28 @@ fn set_bounds_gives_a_butt_path_with_slanted_corners_the_size_out_of_proportion( assert!((v.width() - 60.0).abs() < 1e-6 && (v.height() - 20.0).abs() < 1e-6, "{v:?}"); } +#[test] +fn set_bounds_gives_a_closed_path_with_beveled_or_mitered_corners_the_size_out_of_proportion() { + // The bevel at the triangle's tip, the miter the limit bevels there and the miter spikes it + // keeps reach past the path by margins that follow the slanted segments' directions, which a + // scale out of proportion changes. + let mut wrong = vec![]; + for (join, limit) in [("bevel", 10.0), ("miter", 2.0), ("miter", 10.0)] { + let mut s = session(); + let anchors = json!([{"x": 100, "y": 150}, {"x": 110, "y": 100}, {"x": 120, "y": 150}]); + let id = NodeId(run(&mut s, "path.create", json!({"anchors": anchors, "closed": true}))["id"].as_u64().unwrap()); + run(&mut s, "stroke.set", json!({"weight": 2, "cap": "round", "join": join, "miterLimit": limit})); + preview(&mut s, true); + run(&mut s, "select.set", json!({"ids": [id.0]})); + run(&mut s, "object.setBounds", json!({"width": 60, "height": 20, "reference": 0, "strokes": false})); + let v = s.transform_bounds(&[id]).unwrap(); + if (v.width() - 60.0).abs() > 1e-6 || (v.height() - 20.0).abs() > 1e-6 { + wrong.push(format!("{join} {limit}: {v:?}")); + } + } + assert!(wrong.is_empty(), "{wrong:?}"); +} + #[test] fn set_bounds_gives_a_symbol_instance_of_a_diagonal_butt_line_the_size_out_of_proportion() { let mut s = session(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 468dce2e91..a16bdc7164 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1146,6 +1146,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is measured by its glyph outlines. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy). - A butt cap ends a stroke square at the end of an open path, and a projecting cap extends half the weight past it. VectorCraft's visual box of a butt-capped stroke on an open path does not grow past the path's ends: a straight segment's box comes from the corners of its stroked rectangle, and a curve's box comes from the stroke at its ends and where its tangent is square to an axis. Round and projecting caps and arrowheads still reach past an end. A curve that bends more tightly than half the weight can measure a little larger than it paints, and a butt-dashed path whose first or last dash is a gap is measured to the path's ends. Public reference: [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html). - A butt-capped curve whose first or last handle sits on its end point also stops at that end: a straight 50 pt curve with a 2 pt stroke and its first handle on its start point measures 50 pt tall, not 51 pt (doc `reach::tests::a_butt_curve_with_a_handle_on_its_end_point_stops_near_that_end`). + - A bevel join, and a miter join whose miter limit bevels the corner, end the stroke at the corners of the two segments they join. VectorCraft's visual box of such a stroke, on closed and open paths, reaches a beveled corner only as far as those corners: a 40 × 100 pt triangle with a 20 pt stroke and a miter limit of 2 reaches about 1.96 pt above its tip instead of 10 pt. Round joins and the round caps of dashes, which can end at a corner, still reach half the weight round it, and the projecting caps of dashes reach the diagonal of their square. With Use Preview Bounds on, `object.setBounds` measures an object as drawn when a beveled or mitered corner, or a butt cap, is on a segment at a slant, so a width and height out of proportion give the size typed. A click past a bevel or a butt cap, by more than the click tolerance, does not select the stroke, except on a stroke with a width profile or with dashes that have round or projecting caps, and at the end of a curve that bends more tightly than half the weight. Public references: [Apply stroke color, width, and alignment](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/apply-stroke-color-width-and-alignment.html), [PDF 32000-1:2008](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf), 8.4.3.4 "Line Join Style" and 8.4.3.5 "Miter Limit" (doc `reach::tests::a_bevel_stops_at_the_corners_of_the_segments_it_joins` and `a_click_past_a_bevel_or_a_butt_end_misses`, effects `stroke::tests_reach::beveled_corners_are_measured_where_they_paint`, engine `tests::previewbounds::set_bounds_gives_a_closed_path_with_beveled_or_mitered_corners_the_size_out_of_proportion`). - With Use Preview Bounds on, the bounding box, the Transform panel, Align, Distribute, Distribute Spacing and `object.setBounds` measure each object after its geometry effects. A path is measured by its effected shape, and a fill or stroke with geometry effects of its own is measured by the shape it paints. Type, images, live objects and symbol instances are measured by their reshaped art, and a symbol instance is also measured by its reshaped art when its symbol's art has geometry effects. A group or layer with geometry or Pathfinder effects, or with a fill or stroke of its own that has geometry effects or paints members with geometry effects, is measured by its evaluated art. A blend, envelope or repeat whose objects have geometry effects is measured by its evaluated art, with those objects measured after their effects. A 100 pt square without a stroke, with Offset Path 10, measures 120 pt, and type with Outline Object aligns and sizes by its glyphs. Raster effects are not measured. Public reference: [Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) (archived copy). - VectorCraft's own Measure Type by Glyph Bounds (`typeGlyphBounds`, off by default; Preferences › Type and the Transform panel menu): the bounding box, the Transform panel's, Control bar's and Properties panel's X, Y, W and H, `object.setBounds`, Swap Objects, Replace with Top Object, Set Size of Each and Sort Objects measure each point type object selected as an object by the ink of its glyphs, underline and strikethrough included, instead of its em box. A typed W or H scales the live type until its ink has that size; the font size follows the vertical scale, so a W typed alone, out of proportion, changes the horizontal scale instead. Moved type snaps by its ink box. Type that draws nothing, area type, type on a path and type in a selected group are measured as before, and Align and Distribute follow Align to Glyph Bounds (`tests::typeglyphbounds`, `panels::transform::tests::measure_type_by_glyph_bounds_is_checked_and_resizes_the_box`). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. One light and approximate visibility remain ([revolve.md](revolve.md)). From 9d8df6f27f4934a2e05fb9d66e1b0fa37fc139c5 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 11:05:35 -0800 Subject: [PATCH 79/92] Type: Bunri-Kinshi finds a pair by its clusters in right-to-left justification Single Word Justification adds no space inside an inseparable pair. A right-to-left line draws the second half's cluster before the first half, with the cluster's glyphs in logical order, so a dash followed by a combining mark puts the mark next to the first dash. The pair is now found from the two neighboring clusters instead of neighboring glyph indices. --- crates/text/src/layout/flow.rs | 23 ++++++++++++++++++----- crates/text/src/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 95ee27184f..3b364a00ea 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -743,12 +743,25 @@ fn is_cjk_gap(glyph: usize, held: &[usize], after: &[Option<usize>], index: usiz }) && !in_pair(glyph, index, after, sg) } -// Bunri-Kinshi: are glyph `glyph` and the glyph drawn after it the two halves of an inseparable -// pair? Justification adds no space between them. +// Bunri-Kinshi: are glyph `glyph` and the glyph drawn after it in the two halves of an inseparable +// pair? Justification adds no space between them. The halves are neighboring clusters: the +// earlier one's last glyph keeps the next, and the later one starts right after it. Right to left, +// the clusters are drawn in the other order, each with its glyphs in logical order, so the two +// drawn neighbors need not be neighbors in `sg`. fn in_pair(glyph: usize, index: usize, after: &[Option<usize>], sg: &[SGlyph]) -> bool { - drawn_after(index, glyph, after).is_some_and(|next| { - (next == glyph + 1 && sg.get(glyph).is_some_and(|a| a.keeps_next)) || (glyph == next + 1 && sg.get(next).is_some_and(|b| b.keeps_next)) - }) + let Some(next) = drawn_after(index, glyph, after) else { + return false; + }; + let (Some(a), Some(b)) = (sg.get(glyph), sg.get(next)) else { + return false; + }; + if a.byte == b.byte { + return false; + } + let (first, later) = if a.byte < b.byte { (glyph, b.byte) } else { (next, a.byte) }; + let byte = sg.get(first).map_or(0, |g| g.byte); + let last = first + sg.iter().skip(first + 1).take_while(|g| g.byte == byte).count(); + sg.get(last).is_some_and(|g| g.keeps_next) && sg.get(last + 1).is_some_and(|g| g.byte == later) } // Single Word Justification's Full Justify spreads a line's leftover room over the gaps between diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 345a1af735..bbb7224b75 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -2631,6 +2631,26 @@ fn bunri_kinshi_adds_no_justification_space_inside_a_pair() { let (on, a) = desired(true); assert!((on - natural).abs() < 1e-6, "on: the dashes keep their spacing"); assert!(a > layout(db(), &point(text, style(20.0))).glyphs[0].advance + 1.0, "on: other letters still take Desired spacing"); + // Single Word Justification's Full Justify, right to left: the second dash's cluster (the dash + // and a combining mark) is drawn before the first dash, and the mark's gap, which opens next + // to the first dash, takes no space. The other gaps of the word still do. + let text = "אב——\u{301}גד"; + let (first, second) = ("אב".len(), "אב—".len()); + let width = |l: &TextLayout, b: usize| l.glyphs.iter().filter(|g| g.byte == b).map(|g| g.advance).sum::<f64>(); + let natural = layout(db(), &point(text, style(20.0))); + assert!(natural.glyphs.iter().filter(|g| g.byte == second).count() > 1, "the dash and its mark are one cluster"); + let set = |bunri: bool| { + let mut t = area(text, style(20.0), frame, Justify::JustifyAll); + t.para.bunri_kinshi = bunri; + let l = layout(db(), &t); + assert!((l.lines[0].x1 - frame.x1).abs() < 1e-6, "{bunri}: {:?}", l.lines[0]); + (width(&l, first), width(&l, second)) + }; + let (_, off) = set(false); + assert!(off > width(&natural, second) + 1.0, "off: Full Justify widens the gap between the dashes"); + let (dash, on) = set(true); + assert!((on - width(&natural, second)).abs() < 1e-6, "on: the pair keeps its spacing right to left"); + assert!(dash > width(&natural, first) + 1.0, "on: the gap on the pair's other side still widens"); } /// No Break (Character panel menu) keeps the marked words on one line, where the same text wraps From 6da0e0f115944b97e77543c00243b141f7418761 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 11:29:37 -0800 Subject: [PATCH 80/92] Tests: the shadow-on-each-fill test installs its own plug-in id a_shadow_on_each_fill_a_painted_effect_returns_follows_the_transform_after_it installed test.engine.twofills, which a_second_painted_effect_on_a_fill_keeps_the_piece_s_other_fills also installs with a different module. Installed plug-ins are kept for the whole test process, so running the two tests in parallel could run one test's effect with the other's module. The test now installs test.engine.shadowedfills. --- crates/engine/src/tests/plugins.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index be4193a812..f9abd8430d 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -461,11 +461,11 @@ fn a_shadow_on_each_fill_a_painted_effect_returns_follows_the_transform_after_it let mut s = session(); let id = rect(&mut s, 10.0, "#ff0000"); let out = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00"), solid("#0000ff")]}]}).to_string(); - install_wat(&mut s, &tpl::returning(&painting("test.engine.twofills", true), &out)); + install_wat(&mut s, &tpl::returning(&painting("test.engine.shadowedfills", true), &out)); let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); for (effect, params) in [ ("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 50})), - ("plugin.test.engine.twofills", json!({})), + ("plugin.test.engine.shadowedfills", json!({})), ("distort.transform", json!({"scaleH": 200, "scaleV": 200})), ] { run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "params": params, "item": fill})); From 0691e1acbee30059f35f7acbe116967c3d4e5e07 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 11:30:44 -0800 Subject: [PATCH 81/92] Gradients: keep every run of a repeated gradient with many stops When a repeated or reflected gradient needs more stops than a limit allows, each run now leaves out the stops that change its colors least, so every run is still written, instead of writing fewer runs and padding the rest with the last color. Each run keeps its ends, sharp steps, swatch-linked stops, tint and CMYK ink changes and spans shaped by an off-center midpoint, and the colors are compared as the gradient's method mixes them. This applies to gradients along or across a stroke, CSS conic gradients, and PDF, EPS and .ai export. - Outline Stroke's mesh fits the gradient to at most 1,024 stops, and splits a stroke into several meshes when one would hold more than 2^20 points. - Object > Expand leaves a gradient as it is when the box needs more than 256 runs, instead of expanding only the first runs. - CSS writes the stop positions of repeating gradients with four decimals, so the period stays in place across the box. - A swatch chip of a gradient that repeats gets at least four grid cells a run along each side (at most one a point, and 128), so it does not show one color when the count matches the chip's cells. - docs/mcp.md states the stop limits and the PDF import sample budget. --- crates/color/src/gradient/spread.rs | 126 ++++++++++++++++++-- crates/color/src/gradient/spread/tests.rs | 122 ++++++++++++++++++- crates/effects/src/stroke/gradient.rs | 33 ++++- crates/effects/src/stroke/tests_gradient.rs | 29 +++++ crates/engine/src/cmd/expand.rs | 23 ++-- crates/engine/src/tests/objexpand.rs | 20 ++++ crates/svg/src/css.rs | 5 +- crates/svg/tests/it/css_rules.rs | 7 ++ crates/ui-egui/src/widgets.rs | 33 +++++ crates/ui-egui/src/widgets/chips.rs | 38 +++++- docs/mcp.md | 15 ++- 11 files changed, 411 insertions(+), 40 deletions(-) diff --git a/crates/color/src/gradient/spread.rs b/crates/color/src/gradient/spread.rs index e449151d89..8f9933c40c 100644 --- a/crates/color/src/gradient/spread.rs +++ b/crates/color/src/gradient/spread.rs @@ -4,7 +4,8 @@ use kurbo::{Point, Rect, Vec2}; -use super::{Gradient, GradientGeom, GradientKind, GradientSpread, GradientStop}; +use super::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientSpread, GradientStop}; +use crate::Color; /// Most runs of the stops [`Gradient::padded_over`] writes. pub const MAX_SPREAD_RUNS: usize = 256; @@ -33,8 +34,8 @@ pub struct Padded { pub first: f64, /// How many runs are written, evenly spaced from [`Self::first`] to 1. pub runs: usize, - /// The area needed more runs than [`MAX_SPREAD_RUNS`] (or than [`MAX_SPREAD_STOPS`] allows), - /// or runs past [`MAX_SPREAD_LEVEL`]: the gradient pads past the runs written. + /// The area needed more runs than [`MAX_SPREAD_RUNS`] (or more than half the stops allowed, + /// two to a run), or runs past [`MAX_SPREAD_LEVEL`]: the gradient pads past the runs written. pub capped: bool, } @@ -50,7 +51,9 @@ impl Gradient { self.padded_within(geom, area, MAX_SPREAD_STOPS) } - /// [`Self::padded_over`] with at most `max_stops` stops (and at least one run). + /// [`Self::padded_over`] with at most `max_stops` stops (and at least one run). When the runs + /// written need more stops than that, each run leaves out the stops that change its colors + /// least ([`fit_run`]), so every run is still written. pub fn padded_within(&self, geom: &GradientGeom, area: Rect, max_stops: usize) -> Option<Padded> { let kind = self.kind; if !self.is_spread() || matches!(kind, GradientKind::Conic | GradientKind::Freeform) || self.stops.is_empty() { @@ -68,8 +71,8 @@ impl Gradient { // smallest 0 when the area holds the rings' center, else on its edge. let lo = if centered { inner_level(&period, kind, area).floor() } else { ts.iter().copied().fold(f64::INFINITY, f64::min).floor() }; let hi = ts.iter().copied().fold(f64::NEG_INFINITY, f64::max).ceil().max(lo + 1.0); - let one = whole_run(&self.stops); - let most = MAX_SPREAD_RUNS.min(max_stops.min(MAX_SPREAD_STOPS) / one.len().max(1)).max(1); + let budget = max_stops.min(MAX_SPREAD_STOPS); + let most = MAX_SPREAD_RUNS.min(budget / 2).max(1); let capped = hi - lo > most as f64; // Capped, the runs kept are the area's innermost ones (radial and diamond), or (linear) // those around the vector's start where the area reaches it, else the area's runs nearest @@ -79,6 +82,7 @@ impl Gradient { _ => lo, }; let n = (hi - lo).min(most as f64).max(1.0); + let one = fit_run(whole_run(&self.stops), budget / n as usize, self.method); // A radial or diamond writes its runs among the levels from 0, which its stops' offsets // (single-precision fractions of the outermost level) tell apart only so far out, the // nearer the closer its stops lie: past that, the runs end there and the gradient pads. @@ -112,8 +116,9 @@ impl Gradient { self.unrolled_within(MAX_SPREAD_STOPS) } - /// [`Self::unrolled`] with at most `max_stops` stops (and at least one run): past the runs - /// that fit, the last one pads. + /// [`Self::unrolled`] with at most `max_stops` stops: every run, each run without the stops + /// that change its colors least ([`fit_run`]) when the runs need more stops than that. Only with + /// fewer than two stops for each run do the runs past those that fit pad with the last one. pub fn unrolled_within(&self, max_stops: usize) -> std::borrow::Cow<'_, Gradient> { use std::borrow::Cow; if !self.is_spread() { @@ -123,13 +128,29 @@ impl Gradient { if self.stops.is_empty() { return Cow::Owned(Gradient { spread: GradientSpread::Pad, repeats: 1, ..self.clone() }); } - let one = whole_run(&self.stops); let n = self.repeat_count() as usize; - let kept = n.min(max_stops.min(MAX_SPREAD_STOPS) / one.len().max(1)).max(1); + let budget = max_stops.min(MAX_SPREAD_STOPS); + let kept = n.min(budget / 2).max(1); + let one = fit_run(whole_run(&self.stops), budget / kept, self.method); let stops = self.runs(&one, false, 0.0, kept, n as f64); Cow::Owned(Gradient { stops, spread: GradientSpread::Pad, repeats: 1, ..self.clone() }) } + /// The gradient as [`Self::unrolled`] gives it with at most `max_stops` stops (at least two): + /// a gradient that repeats or reflects as [`Self::unrolled_within`] gives it, and one that + /// pads with more stops than that without the stops that change its colors least + /// ([`fit_run`]). Others as they are. + pub fn fitted(&self, max_stops: usize) -> std::borrow::Cow<'_, Gradient> { + use std::borrow::Cow; + if self.is_spread() { + return self.unrolled_within(max_stops); + } + if self.stops.len() <= max_stops.max(2) { + return Cow::Borrowed(self); + } + Cow::Owned(Gradient { stops: fit_run(whole_run(&self.stops), max_stops, self.method), ..self.clone() }) + } + /// One cycle of a gradient that repeats or reflects, which repeated end to end paints it, as /// a gradient that pads: one run of the stops (Repeat), or a run and its mirror (Reflect), /// between 0 and 1, and how many runs that is (1 or 2). `None` for a gradient that pads and @@ -257,6 +278,91 @@ fn whole_run(stops: &[GradientStop]) -> Vec<GradientStop> { out } +/// `one` (a run of a gradient's stops, [`whole_run`]) with at most `budget` stops (at least two): +/// as it is when it fits, else without the stops that change its colors least, left out one at a +/// time. Leaving out a stop costs how far its color lies from the one that mixing the stops still +/// on either side of it by `method` gives there, and how far its CMYK inks, opacity, tint or +/// process plates lie from the line between theirs (a whole unit when its swatch link or color +/// model differs from either of theirs, and a whole unit more when its midpoint or the one before +/// it is off center), so the ends, sharp steps, ink changes and shaped spans stay. A stop whose +/// next stop was left out gets its midpoint centered. The work is the stops times their logarithm. +fn fit_run(one: Vec<GradientStop>, budget: usize, method: GradientMethod) -> Vec<GradientStop> { + use std::cmp::Reverse; + use std::collections::BinaryHeap; + let budget = budget.max(2); + let n = one.len(); + if n <= budget { + return one; + } + let mix = |x: &Color, y: &Color, v: f32| match method { + GradientMethod::Classic => x.lerp_keep_model(y, v), + m => m.mix(x, y, v), + }; + let cost = |a: &GradientStop, s: &GradientStop, b: &GradientStop| -> u32 { + let span = b.offset - a.offset; + let u = if span > 0.0 { ((s.offset - a.offset) / span).clamp(0.0, 1.0) } else { 0.0 }; + // Left out, the span from `a` to `b` has a centered midpoint, so it mixes them by `u`. + let (want, cs) = (mix(&a.color, &b.color, u).to_rgb(), s.color.to_rgb()); + let color = (0..3).map(|k| (want[k] - cs[k]).abs()).fold(0.0, f32::max); + let opacity = (a.opacity + (b.opacity - a.opacity) * u - s.opacity).abs(); + // Process inks, which colors that look alike can print differently: off their line, or a + // change of color model. + let process = match (&a.color, &s.color, &b.color) { + (Color::Cmyk { c: c0, m: m0, y: y0, k: k0 }, Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c1, m: m1, y: y1, k: k1 }) => { + [(c0, c, c1), (m0, m, m1), (y0, y, y1), (k0, k, k1)].iter().map(|(p, q, r)| (*p + (*r - *p) * u - *q).abs()).fold(0.0, f32::max) + } + _ if a.color.model() == s.color.model() && s.color.model() == b.color.model() => 0.0, + _ => 1.0, + }; + let color = color.max(process); + // Separations: a change of swatch link, or of tint or process plates off their line. + let ink = if s.swatch != a.swatch || s.swatch != b.swatch { + 1.0 + } else { + let tint = (a.tint + (b.tint - a.tint) * u - s.tint).abs(); + let plates = match (a.plates, s.plates, b.plates) { + (None, None, None) => 0.0, + (Some(pa), Some(ps), Some(pb)) => (0..4).map(|k| (pa[k] + (pb[k] - pa[k]) * u - ps[k]).abs()).fold(0.0, f32::max), + _ => 1.0, + }; + tint.max(plates) + }; + // Leaving it out merges two spans, and an off-center midpoint shapes one of them. + let shaped = if [a.midpoint, s.midpoint].iter().any(|m| (m - 0.5).abs() > 1e-3) { 1.0 } else { 0.0 }; + // Non-negative, so the bits order as the numbers do (NaN counts as 0). + (color.max(opacity).max(ink) + shaped).max(0.0).to_bits() + }; + // The stops still kept on either side of each stop, and each stop's latest cost entry. + let mut prev: Vec<usize> = (0..n).map(|i| i.saturating_sub(1)).collect(); + let mut next: Vec<usize> = (1..=n).collect(); + let mut gone = vec![false; n]; + let mut version = vec![0u32; n]; + let mut heap: BinaryHeap<Reverse<(u32, usize, u32)>> = (1..n - 1).map(|i| Reverse((cost(&one[i - 1], &one[i], &one[i + 1]), i, 0))).collect(); + let mut left = n; + while left > budget { + let Some(Reverse((_, i, v))) = heap.pop() else { break }; + if gone[i] || v != version[i] { + continue; + } + gone[i] = true; + left -= 1; + let (a, b) = (prev[i], next[i]); + next[a] = b; + prev[b] = a; + for j in [a, b] { + if j != 0 && j != n - 1 { + version[j] += 1; + heap.push(Reverse((cost(&one[prev[j]], &one[j], &one[next[j]]), j, version[j]))); + } + } + } + one.into_iter() + .enumerate() + .filter(|(i, _)| !gone[*i]) + .map(|(i, s)| if next[i] == i + 1 { s } else { GradientStop { midpoint: 0.5, ..s } }) + .collect() +} + /// A run mirrored: the stops in reverse, each midpoint measured from the other side. fn mirror(run: &[GradientStop]) -> Vec<GradientStop> { let mut g = Gradient::new(GradientKind::Linear, run.to_vec()); diff --git a/crates/color/src/gradient/spread/tests.rs b/crates/color/src/gradient/spread/tests.rs index 2465039ca5..a88319ccaa 100644 --- a/crates/color/src/gradient/spread/tests.rs +++ b/crates/color/src/gradient/spread/tests.rs @@ -148,11 +148,12 @@ fn radial_and_diamond_gradients_pad_in_rings_from_their_first_level() { } } -/// A run of the stops far too short for the area, or a gradient of many stops, is written with at -/// most so many runs and stops, and pads past them; a conic, a padding gradient and one without -/// stops have nothing to pad. +/// A run of the stops far too short for the area is written with at most so many runs, and pads +/// past them; a gradient of many stops is written with every run the area needs, each run +/// without the stops that change its colors least, within so many stops; a conic, a padding +/// gradient and one without stops have nothing to pad. #[test] -fn far_repeats_and_many_stops_are_capped() { +fn far_repeats_are_capped_and_many_stops_are_sampled() { let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.01, 0.0), aspect: 1.0, focal: None }; let g = Gradient { spread: GradientSpread::Repeat, repeats: MAX_REPEATS, ..Gradient::new(GradientKind::Linear, black_white()) }; let p = g.padded_over(&geom, Rect::new(0.0, 0.0, 100.0, 1.0)).unwrap(); @@ -162,7 +163,13 @@ fn far_repeats_and_many_stops_are_capped() { let g = Gradient { spread: GradientSpread::Reflect, repeats: 50, ..Gradient::new(GradientKind::Linear, many) }; let wide = GradientGeom { end: Point::new(100.0, 0.0), ..geom }; let p = g.padded_over(&wide, Rect::new(0.0, 0.0, 100.0, 1.0)).unwrap(); - assert!(p.capped && p.gradient.stops.len() <= MAX_SPREAD_STOPS, "{}", p.gradient.stops.len()); + assert!(!p.capped && p.runs == 50 && p.gradient.stops.len() <= MAX_SPREAD_STOPS, "{} {}", p.runs, p.gradient.stops.len()); + for x in [0.7, 33.3, 66.1, 99.5] { + let q = Point::new(x, 0.5); + let want = g.sample(g.spread_param(wide.param_at(GradientKind::Linear, q)) as f32).0.to_rgb(); + let got = p.gradient.sample(p.geom.param_at(GradientKind::Linear, q) as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{x}: {want:?} vs {got:?}"); + } assert!(g.unrolled().stops.len() <= MAX_SPREAD_STOPS); assert!(p.gradient.padded_over(&p.geom, Rect::new(0.0, 0.0, 1.0, 1.0)).is_none(), "it pads"); assert!(spread(GradientKind::Conic, GradientSpread::Repeat, 3).padded_over(&wide, Rect::new(0.0, 0.0, 1.0, 1.0)).is_none()); @@ -325,6 +332,111 @@ fn unrolled_and_cycle_stops() { assert!(matches!(Gradient::default().unrolled(), std::borrow::Cow::Borrowed(_))); } +/// A gradient of more stops than [`MAX_SPREAD_STOPS`] allows for all its runs keeps every run +/// when unrolled, each run without the stops that change its colors least: its last run ends on +/// its last color instead of the rest padding with it. +#[test] +fn unrolled_many_stops_keep_every_run() { + let stops: Vec<GradientStop> = (0..200).map(|i| GradientStop::new(i as f32 / 199.0, Color::gray(i as f32 / 199.0))).collect(); + let g = Gradient { spread: GradientSpread::Repeat, repeats: MAX_REPEATS, ..Gradient::new(GradientKind::Linear, stops) }; + let u = g.unrolled(); + assert!(u.stops.len() <= MAX_SPREAD_STOPS, "{}", u.stops.len()); + for t in [0.005, 0.5051, 0.9149, 0.9951] { + let want = g.sample(g.spread_param(t) as f32).0.to_rgb(); + let got = u.sample(t as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{t}: {want:?} vs {got:?}"); + } + // A padding gradient fitted to fewer stops keeps its ends. + let pad = Gradient::new(GradientKind::Linear, g.stops.clone()); + let fitted = pad.fitted(16); + assert_eq!(fitted.stops.len(), 16); + for (stop, want) in [(&fitted.stops[0], Color::gray(0.0)), (&fitted.stops[15], Color::gray(1.0))] { + assert!(stop.color.to_rgb().iter().zip(want.to_rgb()).all(|(a, b)| (a - b).abs() < 1e-5), "{:?}", stop.color); + } + assert!(matches!(pad.fitted(500), std::borrow::Cow::Borrowed(_))); +} + +/// One run of more stops than the limit (255 inside it, and a copy of each end color at 0 and 1) +/// is written within the limit, every run the area needs included. +#[test] +fn a_run_over_the_stop_limit_is_written_within_it() { + let stops: Vec<GradientStop> = (1..=255).map(|i| GradientStop::new(i as f32 / 256.0, Color::gray(i as f32 / 256.0))).collect(); + let g = Gradient { spread: GradientSpread::Repeat, repeats: 2, ..Gradient::new(GradientKind::Linear, stops) }; + let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(20.0, 0.0), aspect: 1.0, focal: None }; + let p = g.padded_within(&geom, Rect::new(0.0, 0.0, 20.0, 1.0), 256).unwrap(); + assert!(!p.capped && p.runs == 2 && p.gradient.stops.len() <= 256, "{} {} {}", p.capped, p.runs, p.gradient.stops.len()); + assert!(g.unrolled_within(256).stops.len() <= 256); +} + +/// Fitting a run to fewer stops keeps a narrow band between two sharp steps, a stop linked to a +/// swatch, and each kept stop's link and tint, where sampling at evenly spaced places would lose +/// them. +#[test] +fn fitting_keeps_sharp_steps_and_swatch_links() { + let mut stops: Vec<GradientStop> = (0..300).map(|i| GradientStop::new(i as f32 / 299.0, Color::gray(i as f32 / 299.0))).collect(); + let red = Color::rgb(1.0, 0.0, 0.0); + // A band of red from 0.5003 to 0.5013, with a sharp step at each side. + for (offset, color) in [(0.5003, None), (0.5003, Some(red)), (0.5013, Some(red)), (0.5013, None)] { + let gray = Color::gray(offset); + stops.push(GradientStop::new(offset, color.unwrap_or(gray))); + } + let spot = GradientStop { swatch: Some("Ink".into()), tint: 0.4, ..GradientStop::new(0.25, Color::gray(0.25)) }; + stops.push(spot.clone()); + // Three stops of one ink that look alike but print at different tints. + for (offset, tint) in [(0.8, 0.2), (0.81, 0.9), (0.82, 0.2)] { + stops.push(GradientStop { swatch: Some("Ink2".into()), tint, ..GradientStop::new(offset, Color::gray(offset)) }); + } + // A span shaped by an off-center midpoint. + if let Some(s) = stops.iter_mut().find(|s| (s.offset - 0.6).abs() < 2e-3) { + s.midpoint = 0.13; + } + stops.sort_by(|a, b| a.offset.total_cmp(&b.offset)); + let g = Gradient::new(GradientKind::Linear, stops); + let fitted = g.fitted(32); + assert!(fitted.stops.len() <= 32, "{}", fitted.stops.len()); + let red_at = |t: f32| fitted.sample(t).0.to_rgb(); + assert_eq!(red_at(0.5008), [1.0, 0.0, 0.0], "the band stays"); + assert!(red_at(0.499)[1] > 0.4 && red_at(0.503)[1] > 0.4, "the band stays narrow"); + assert!(fitted.stops.iter().any(|s| s.swatch.as_deref() == Some("Ink") && s.tint == 0.4 && s.offset == 0.25), "{:?}", fitted.stops); + assert!(fitted.stops.iter().any(|s| s.swatch.as_deref() == Some("Ink2") && s.tint == 0.9), "the tint change stays"); + assert!(fitted.stops.iter().any(|s| s.midpoint == 0.13), "the shaped span stays"); + // Of a stop that changes only its ink's tint and one that the colors' line nearly gives, the + // first stays. (The stop before an ink starts stays as well: leaving it out would blend the + // ink in sooner.) + let ink = |offset: f32, tint: f32| GradientStop { swatch: Some("Ink2".into()), tint, ..GradientStop::new(offset, Color::gray(0.5)) }; + let few = vec![ + GradientStop::new(0.0, Color::gray(0.0)), + GradientStop::new(0.2, Color::gray(0.17)), + GradientStop::new(0.3, Color::gray(0.3)), + ink(0.4, 0.2), + ink(0.5, 0.9), + ink(0.6, 0.2), + GradientStop::new(1.0, Color::gray(1.0)), + ]; + let fitted = Gradient::new(GradientKind::Linear, few).fitted(6).into_owned(); + let offsets: Vec<f32> = fitted.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.3, 0.4, 0.5, 0.6, 1.0]); + // A Perceptual gradient leaves out the stop its own mixing gives, not the one the RGB line + // gives. + let (blue, yellow, gray) = (Color::rgb(0.0, 0.0, 1.0), Color::rgb(1.0, 1.0, 0.0), Color::rgb(0.5, 0.5, 0.5)); + let on_curve = GradientMethod::Perceptual.mix(&gray, &yellow, 0.5); + let stops = vec![GradientStop::new(0.0, blue), GradientStop::new(0.5, gray), GradientStop::new(0.75, on_curve), GradientStop::new(1.0, yellow)]; + let perceptual = Gradient { method: GradientMethod::Perceptual, ..Gradient::new(GradientKind::Linear, stops) }; + let fitted = perceptual.fitted(3).into_owned(); + let offsets: Vec<f32> = fitted.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.5, 1.0]); + // A black of other inks between blacks that look the same prints differently: it stays. + let k = |offset: f32| GradientStop::new(offset, Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0 }); + let rich = GradientStop::new(0.5, Color::Cmyk { c: 0.6, m: 0.4, y: 0.4, k: 1.0 }); + let blacks = vec![k(0.0), k(0.4), rich, k(0.6), k(1.0)]; + let fitted = Gradient::new(GradientKind::Linear, blacks).fitted(3).into_owned(); + let offsets: Vec<f32> = fitted.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.5, 1.0]); + let spread = Gradient { spread: GradientSpread::Repeat, repeats: 4, ..g.clone() }; + let u = spread.unrolled_within(128); + assert_eq!(u.stops.iter().filter(|s| s.swatch.as_deref() == Some("Ink")).count(), 4, "one linked stop a run"); +} + /// Pixels (what the canvas draws a diamond, dithered or Perceptual gradient as, and what SVG and /// PDF write as images) repeat with the stops, for each kind. #[test] diff --git a/crates/effects/src/stroke/gradient.rs b/crates/effects/src/stroke/gradient.rs index ec54be20cd..ffd128b6dd 100644 --- a/crates/effects/src/stroke/gradient.rs +++ b/crates/effects/src/stroke/gradient.rs @@ -32,6 +32,12 @@ const SHARP: f64 = 1.25; const HAIRPIN: f64 = 4.0; /// Rows of a mesh across the stroke (more where the gradient has stops). const MESH_ROWS: usize = 16; +/// The most stops of the gradient [`gradient_meshes`] puts rows or columns at +/// ([`Gradient::fitted`]: past it, the stops that change the colors least are left out). +pub(crate) const MAX_MESH_STOPS: usize = 1024; +/// The most points of one mesh [`gradient_meshes`] makes, unless a single pair of ribs needs more: +/// a subpath with more ribs than leave room for a row at each stop becomes several meshes. +pub(crate) const MAX_MESH_POINTS: usize = 1 << 20; /// The most of a subpath one column of a mesh along the stroke spans. const MESH_STEP: f64 = 1.0 / 32.0; /// The most of a subpath one slice spans while a width profile shapes the across gradient. @@ -513,26 +519,33 @@ fn mesh_marks(g: &Gradient) -> Vec<f64> { /// Outline Stroke of stroke `st` of `bp` (filled with `rule`) whose gradient `g` runs along or /// across it: a gradient mesh per subpath over all the stroke can paint there (clip it to the -/// stroke's outline), coloured as [`gradient_slices`] paint it. +/// stroke's outline), coloured as [`gradient_slices`] paint it. The gradient's stops are fitted to +/// [`MAX_MESH_STOPS`], and a subpath whose mesh would hold more than [`MAX_MESH_POINTS`] points is +/// split into meshes of fewer ribs ([`rib_pieces`]). pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradient) -> Vec<GradientMesh> { - let g = &*g.unrolled(); + let fitted = g.fitted(MAX_MESH_STOPS); let across = st.gradient_mode == StrokeGradientMode::Across; - let color = |t: f64| g.sample_keep_model(t.clamp(0.0, 1.0) as f32); - let at_stops = mesh_marks(g); // Along: a column at each stop (colours are linear between columns), close enough to look smooth. - let marks: Vec<f64> = if across { vec![] } else { at_stops.clone() }; + let marks: Vec<f64> = if across { vec![] } else { mesh_marks(&fitted) }; let max_dt = match (across, st.profile.is_some()) { (false, _) => MESH_STEP, (true, true) => PROFILE_STEP, (true, false) => 1.0, }; // Rows across: the strip's edges, then (across) the band's left edge to its right edge with a row at each stop. - let mut us: Vec<f64> = if across { (0..=MESH_ROWS).map(|i| i as f64 / MESH_ROWS as f64).chain(at_stops).collect() } else { vec![0.5] }; + let mut us: Vec<f64> = if across { (0..=MESH_ROWS).map(|i| i as f64 / MESH_ROWS as f64).chain(mesh_marks(&fitted)).collect() } else { vec![0.5] }; us.sort_by(f64::total_cmp); us.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + let us = &us; + let g = &*fitted; + let color = |t: f64| g.sample_keep_model(t.clamp(0.0, 1.0) as f32); + // A mesh is a point for each row (and its two edges) on each rib: a strip with more ribs than + // that leaves room for is split into meshes of fewer ribs. + let per_mesh = MAX_MESH_POINTS / (us.len() + 2); strips(bp, rule, st, SLICE_TOL, SLICE_MARGIN, &marks, max_dt) .into_iter() .filter(|ribs| ribs.len() > 1) + .flat_map(|ribs| rib_pieces(ribs.len(), per_mesh).into_iter().map(move |r| ribs.get(r).unwrap_or_default().to_vec())) .map(|ribs| { let (rows, cols) = (us.len() + 1, ribs.len() - 1); // Each level across: a point on each rib and the gradient position there. @@ -569,3 +582,11 @@ pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradi }) .collect() } + +/// The ribs (index ranges of `n`, at least two) of each mesh [`gradient_meshes`] makes of a strip: +/// at most `per_mesh` ribs each (at least two), each starting on the last rib of the one before, +/// so the meshes meet without a gap. +pub(crate) fn rib_pieces(n: usize, per_mesh: usize) -> Vec<std::ops::Range<usize>> { + let per_mesh = per_mesh.max(2); + (0..n.saturating_sub(1)).step_by(per_mesh - 1).map(|start| start..(start + per_mesh).min(n)).collect() +} diff --git a/crates/effects/src/stroke/tests_gradient.rs b/crates/effects/src/stroke/tests_gradient.rs index 3745e4a6f6..784c84d1db 100644 --- a/crates/effects/src/stroke/tests_gradient.rs +++ b/crates/effects/src/stroke/tests_gradient.rs @@ -345,6 +345,35 @@ fn a_narrow_peak_where_a_reflecting_gradient_turns_back_stays_in_the_mesh() { } } +/// Outline Stroke's mesh across a stroke of many ribs, with a gradient of many stops repeated 100 +/// times: each mesh holds at most so many points, and every run still starts on its first color. +#[test] +fn a_mesh_across_many_ribs_and_stops_stays_bounded() { + use vectorcraft_color::GradientSpread; + let pts: Vec<(f64, f64)> = (0..1200).map(|i| (i as f64 * 10.0, if i % 2 == 0 { 0.0 } else { 10.0 })).collect(); + let bp = poly(&pts, false); + let stops: Vec<GradientStop> = (0..200).map(|i| GradientStop::new(i as f32 / 199.0, Color::gray(1.0 - i as f32 / 199.0))).collect(); + let g = Gradient { spread: GradientSpread::Repeat, repeats: 100, ..Gradient::new(vectorcraft_color::GradientKind::Linear, stops) }; + let mut st = stroke(4.0, StrokeGradientMode::Across); + st.paint = Paint::Gradient(Box::new(GradientPaint::new(g.clone()))); + let meshes = gradient_meshes(&bp, FillRule::NonZero, &st, &g); + assert!(!meshes.is_empty()); + for m in &meshes { + assert!(m.points.len() <= super::gradient::MAX_MESH_POINTS, "{} points", m.points.len()); + // A row at the start of each of the 100 runs, on every rib, is white. + let light = m.points.iter().filter(|p| p.color.to_rgb()[0] > 0.97).count(); + assert!(light >= 100 * (m.cols as usize + 1), "{light} light points of {}", m.points.len()); + } + // A strip of more ribs than one mesh has room for is split into meshes that share their end + // ribs, every rib in one of them. + use super::gradient::rib_pieces; + assert_eq!(rib_pieces(5, 3), [0..3, 2..5]); + assert_eq!(rib_pieces(4, 3), [0..3, 2..4]); + let one = rib_pieces(2, 1000); + assert_eq!((one.len(), one.first()), (1, Some(&(0..2)))); + assert_eq!(rib_pieces(3, 0), [0..2, 1..3]); +} + /// #5960: with dashes, the ribs reach as far as the stroke gets wide within a cap's length along /// the path, however many points lie that far: not as far as it is wide anywhere when more than /// 256 do. diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 5737235963..bcf883b2f3 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> { "Expand…", ["Object"], None, - "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255; a gradient that repeats or reflects gets the same whole number of them in each run, at least one, so a run starts on an object's edge)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}", + "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255; a gradient that repeats or reflects gets the same whole number of them in each run, at least one, so a run starts on an object's edge; one that needs more than 256 runs across the box stays a gradient)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}", has_selection, expand ), @@ -476,8 +476,9 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F let m = GradientMesh::for_path_with(path, FREEFORM_MESH, FREEFORM_MESH, &*gradient_sampler(g, bounds, d.color_mode.model()))?; vec![Node::new(d.alloc_id(), NodeKind::Mesh(m))] } else { - // A gradient that repeats or reflects expands as its stops repeated over the box. - let (g, runs) = padded_on(g, bounds); + // A gradient that repeats or reflects expands as its stops repeated over the box (and + // stays a gradient when the box needs more runs than are written). + let (g, runs) = padded_on(g, bounds)?; let g = &*g; let frame = Frame::new(g, bounds); match to { @@ -1241,26 +1242,32 @@ pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect, doc_model: Model Box::new(move |p| g.gradient.sample_keep_model(spread(geom.param_at(g.gradient.kind, p)) as f32)) } +/// For stops repeated by [`padded_on`]: where along the vector (0 to 1) their first run starts and +/// how many runs lie evenly from there to 1. +type Runs = Option<(f64, usize)>; + /// `g` placed on box `bounds` as a gradient that pads past its ends: a linear, radial or diamond /// gradient that repeats or reflects with its stops repeated as many times as the box needs /// ([`vectorcraft_color::Gradient::padded_over`]), a conic one with its stops repeated around the /// turn ([`vectorcraft_color::Gradient::unrolled`]); others as they are. With it, for the repeated /// stops, where along the vector (0 to 1) their first run starts and how many runs lie evenly from -/// there to 1. -fn padded_on(g: &GradientPaint, bounds: Rect) -> (std::borrow::Cow<'_, GradientPaint>, Option<(f64, usize)>) { +/// there to 1. `None` when the box needs more runs than are written +/// ([`vectorcraft_color::Padded::capped`]): the gradient stays. +fn padded_on(g: &GradientPaint, bounds: Rect) -> Option<(std::borrow::Cow<'_, GradientPaint>, Runs)> { use std::borrow::Cow; if !g.gradient.is_spread() { - return (Cow::Borrowed(g), None); + return Some((Cow::Borrowed(g), None)); } let geom = g.resolve(bounds); match g.gradient.padded_over(&geom, bounds) { + Some(p) if p.capped => None, Some(p) => { let runs = Some((p.first, p.runs)); - (Cow::Owned(GradientPaint { gradient: p.gradient, geom: Some(p.geom), angle: p.geom.angle_deg(), ..g.clone() }), runs) + Some((Cow::Owned(GradientPaint { gradient: p.gradient, geom: Some(p.geom), angle: p.geom.angle_deg(), ..g.clone() }), runs)) } None => { let runs = Some((0.0, g.gradient.repeat_count() as usize)); - (Cow::Owned(GradientPaint { gradient: g.gradient.unrolled().into_owned(), geom: Some(geom), ..g.clone() }), runs) + Some((Cow::Owned(GradientPaint { gradient: g.gradient.unrolled().into_owned(), geom: Some(geom), ..g.clone() }), runs)) } } } diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 3dacee533b..65d638e641 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -508,6 +508,26 @@ fn a_spread_gradient_expands_into_whole_bands_for_each_run() { } } +/// A gradient that repeats across a box in more runs than are written (700 runs of 0.2 pt) is not +/// expanded into its first runs and a flat last color: the fill stays a gradient, and the rest of +/// the object (its stroke) still expands. +#[test] +fn a_spread_gradient_with_more_runs_than_written_stays_a_gradient() { + for to in [json!({"steps": 240}), json!({"gradient": "mesh"})] { + let mut s = session(); + let gr = json!({"kind": "linear", "spread": "repeat", "repeats": 100, "start": [30, 100], "end": [50, 100], + "stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 1, "color": "#004080"}]}); + gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gr, true); + let before = render(&s); + run(&mut s, "object.expand", to.clone()); + let after = render(&s); + assert!(mean_diff(&before, &after) < 2.0, "{to}: {}", mean_diff(&before, &after)); + let mut gradients = 0; + s.doc().unwrap().doc.walk(|n| gradients += usize::from(matches!(n.appearance.fill_paint(), Paint::Gradient(_)))); + assert_eq!(gradients, 1, "{to}"); + } +} + #[test] fn the_mesh_option_makes_a_mesh_that_paints_the_gradient() { for gradient in [ diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index 79d4d52c11..da2049fe8d 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -582,9 +582,12 @@ impl Rules<'_> { } _ => (unrolled, geom, ""), }; + // A repeating gradient's period is the span from its first stop to its last, repeated + // across the box: its positions get four decimals, so the period stays in place there. + let decimals = if repeating.is_empty() { 2 } else { 4 }; let stops = |pos: &dyn Fn(f32) -> f64| { let stops: Vec<String> = - gradient.expanded().map(|(t, c, o, _)| format!("{} {}%", color_css(&c, o * opacity), fmt_num(pos(t) * 100.0, 2))).collect(); + gradient.expanded().map(|(t, c, o, _)| format!("{} {}%", color_css(&c, o * opacity), fmt_num(pos(t) * 100.0, decimals))).collect(); stops.join(", ") }; match g.gradient.kind { diff --git a/crates/svg/tests/it/css_rules.rs b/crates/svg/tests/it/css_rules.rs index 2ca6dcc30b..8abd6eb48b 100644 --- a/crates/svg/tests/it/css_rules.rs +++ b/crates/svg/tests/it/css_rules.rs @@ -359,6 +359,13 @@ fn spread_gradients_give_repeating_css_gradients() { css(GradientKind::Linear, GradientSpread::Reflect, across).as_deref(), Some("repeating-linear-gradient(90deg, #ffffff 0%, #000000 25%, #000000 25%, #ffffff 50%)") ); + // A period that is not a whole hundredth of the box keeps four decimals, so it does not drift + // across the box. + let short = GradientGeom { end: Point::new(10.3452, 30.0), ..across }; + assert_eq!( + css(GradientKind::Linear, GradientSpread::Repeat, short).as_deref(), + Some("repeating-linear-gradient(90deg, #ffffff 0%, #000000 2.5863%)") + ); let ring = GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(80.0, 30.0), aspect: 1.0, focal: None }; assert_eq!( css(GradientKind::Radial, GradientSpread::Reflect, ring).as_deref(), diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 46fde93aad..826e7e02f6 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -759,6 +759,39 @@ mod gradient_chip_tests { } } + /// A gradient that repeats or reflects as many times as a chip has cells (8 on a 24-point chip) + /// still shows its runs: the grid gets more cells than runs, so its points do not all sample + /// the same place in a run. + #[test] + fn a_chip_repeated_once_a_cell_is_not_one_color() { + for spread in [vectorcraft_color::GradientSpread::Repeat, vectorcraft_color::GradientSpread::Reflect] { + let mut gp = paint(GradientKind::Linear, 0.0); + gp.gradient.spread = spread; + gp.gradient.repeats = 8; + let (cols, rows) = chips::chip_grid(&gp.gradient, 24.0, 24.0); + assert!(cols > 8 && rows > 8, "{spread:?}: {cols} x {rows}"); + let chip = gradient_chip_colors(&gp, 24.0, 24.0, cols, rows); + let row: std::collections::HashSet<Color32> = chip.iter().take(cols + 1).copied().collect(); + assert!(row.len() > 2, "{spread:?}: {row:?}"); + } + // A gradient that pads keeps a cell about every 3 points. + assert_eq!(chips::chip_grid(&Gradient::default(), 24.0, 24.0), (8, 8)); + // Many runs give a small chip at most a cell a point. + let mut many = paint(GradientKind::Linear, 0.0).gradient; + many.spread = vectorcraft_color::GradientSpread::Repeat; + many.repeats = 100; + assert_eq!(chips::chip_grid(&many, 24.0, 12.0), (24, 12)); + assert_eq!(chips::chip_grid(&many, 400.0, 400.0), (128, 128)); + // A count that matches a chip's points still shows its runs. + let mut gp = paint(GradientKind::Linear, 0.0); + gp.gradient.spread = vectorcraft_color::GradientSpread::Repeat; + gp.gradient.repeats = 23; + let (cols, rows) = chips::chip_grid(&gp.gradient, 23.0, 23.0); + let chip = gradient_chip_colors(&gp, 23.0, 23.0, cols, rows); + let row: std::collections::HashSet<Color32> = chip.iter().take(cols + 1).copied().collect(); + assert!(row.len() > 2, "{cols}: {row:?}"); + } + #[test] fn radial_chip_runs_from_the_centre_out() { let colors = gradient_chip_colors(&paint(GradientKind::Radial, 0.0), 20.0, 20.0, 4, 4); diff --git a/crates/ui-egui/src/widgets/chips.rs b/crates/ui-egui/src/widgets/chips.rs index 87c6034bda..1b814728a1 100644 --- a/crates/ui-egui/src/widgets/chips.rs +++ b/crates/ui-egui/src/widgets/chips.rs @@ -33,11 +33,7 @@ pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient if !(rect.width() > 0.0 && rect.height() > 0.0) { return; } - // Freeform sampling is costly per spot, so it gets a coarse grid (the mesh interpolates). - let freeform = gradient.gradient.kind == vectorcraft_color::GradientKind::Freeform; - let max = if freeform { 16 } else { 64 }; - let cols = ((rect.width() / 3.0).ceil() as usize).clamp(1, max); - let rows = ((rect.height() / 3.0).ceil() as usize).clamp(1, max); + let (cols, rows) = chip_grid(&gradient.gradient, rect.width(), rect.height()); let colors = gradient_chip_colors(gradient, rect.width(), rect.height(), cols, rows); // Stops below full opacity show a checkerboard through them. if colors.iter().any(|c| c.a() < u8::MAX) { @@ -59,6 +55,38 @@ pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient ui.painter().add(egui::Shape::mesh(mesh)); } +/// Most grid cells across (and down) a chip of a gradient that repeats or reflects. +const SPREAD_CHIP_CELLS: usize = 128; + +/// The grid (columns, rows) a `width` x `height` chip of gradient `g` is sampled on: a cell about +/// every 3 points (at most 64 a side, 16 for a freeform gradient, whose sampling is costly per +/// spot; the mesh interpolates between them), and for a gradient that repeats or reflects at +/// least 4 cells a run along each side (at most one a point, and [`SPREAD_CHIP_CELLS`]), so the +/// grid's points do not all fall at the same place in a run and paint the chip one color. For +/// such a gradient a side gets a cell or two more when its points would fall at fewer than three +/// places in a run. +pub(super) fn chip_grid(g: &vectorcraft_color::Gradient, width: f32, height: f32) -> (usize, usize) { + let freeform = g.kind == vectorcraft_color::GradientKind::Freeform; + let max = if freeform { 16 } else { 64 }; + let runs = if g.is_spread() { g.repeat_count() as usize } else { 1 }; + let least = if runs > 1 { (4 * runs).min(SPREAD_CHIP_CELLS) } else { 1 }; + let gcd = |mut a: usize, mut b: usize| { + while b != 0 { + (a, b) = (b, a % b); + } + a + }; + let side = |len: f32| { + let mut cells = ((len / 3.0).ceil() as usize).clamp(1, max).max(least.min(len.ceil() as usize)); + // Points `i / cells` of the way along fall at `cells / gcd(cells, runs)` places in a run. + while runs > 1 && cells / gcd(cells, runs) < 3 { + cells += 1; + } + cells + }; + (side(width), side(height)) +} + /// A white and light gray checkerboard filling `rect`, with cells a quarter of its shorter side /// (3 to 8 points). fn checkerboard(ui: &Ui, rect: Rect) { diff --git a/docs/mcp.md b/docs/mcp.md index 8181e111a3..f9df09112d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1204,11 +1204,16 @@ is written with its stops repeated as many times as the shape needs: at most 256 legacy `.ai`), and for a radial or diamond gradient no farther from its center than 65,536 runs, or than a million times the smallest gap between its stops (measured in runs) when that is less. Past those it pads, with a warning. The `.ai` editing data has no spread either: it holds the stops repeated across the shape, at most 256 of them, with -a warning. PDF import finds a sharp step between two colors in an axial or radial shading, in any PDF, by halving -the spans between its samples, so the step reads back where it is in the file (to 1/64 × 2^-14 of the axis, with at -most 16,384 samples a shading) instead of blurred across the samples around it. Object › Expand expands the -repeated stops, into the same whole number of `steps` objects for each run (at least one, so about `steps` in all), -each run starting on an object's edge: +a warning. When the runs need more stops than these limits allow, every run is still written, without the stops that +change its colors least (its ends, sharp steps and swatch-linked stops stay); the same applies to a gradient along +or across a stroke, and to CSS's conic gradient, past 16,384 stops, and to Outline Stroke's mesh past 1,024. PDF +import finds a sharp step between two colors in an axial or radial shading by halving the spans between its samples, +so the step reads back where it is in the file (to 1/64 × 2^-14 of the axis) instead of blurred across the samples +around it. A shading takes at most 16,384 samples, so the steps past the point where that budget runs out stay +blurred, and a color band narrower than 1/64 of the axis can be missed when no sample lands in it. Object › Expand +expands the repeated stops, into the same whole number of `steps` objects for each run (at least one, so about +`steps` in all), each run starting on an object's edge; a gradient that needs more than 256 runs across the box stays +a gradient: ```json {"name":"run_command","arguments":{"command":"paint.editGradient","params":{"spread":"reflect","repeats":3}}} From eb80d45ec5ad9817be0f6105ad85bfb057ddffd9 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 08:31:45 -0800 Subject: [PATCH 82/92] 3D and Materials: Expand as Wireframes takes a compound path member as its compound path A path inside a compound path given in ids was replaced by a group inside the compound path, which reads its members as paths, so the edges did not draw. The compound path is now expanded in its place. The panel's Quick Actions show on the Object tab of Revolve (Classic), not on its Rotation tab, as for the other effects with an Object tab. --- crates/engine/src/cmd/wireframe.rs | 6 ++++-- crates/engine/src/cmd/wireframe/tests.rs | 17 +++++++++++++++++ crates/ui-egui/src/panels/tests_three_d.rs | 16 ++++++++++++++++ crates/ui-egui/src/panels/three_d.rs | 4 ++-- docs/mcp.md | 2 +- 5 files changed, 40 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/wireframe.rs b/crates/engine/src/cmd/wireframe.rs index 595b837aa5..eeee004b07 100644 --- a/crates/engine/src/cmd/wireframe.rs +++ b/crates/engine/src/cmd/wireframe.rs @@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> { "Expand as Wireframes", [], None, - "{ids?: [..] (default: the selected objects)} replace each object that has a visible 3D effect (Extrude & Bevel, Revolve, Inflate, Rotate or one of their 3D (Classic) forms, on the object or on its fills and strokes) with a group of paths: the edges of the faces the effect draws, as the object is turned and projected now, each edge once (edges that meet end to end make one path, closed where it comes back to its start; curved edges stay curves; geometry effects after the 3D effect, on the object or on a fill or stroke, reshape them too), stroked 0.5 pt black with round joins and no fill (VectorCraft's own); the group takes the object's place, id, name, opacity, blending mode, opacity mask, attributes, slice and text wrap and is selected (a layer stays a layer holding the edges, which are selected, and its ruler guides and paths made into guides); objects without a visible 3D effect are left as they are; one undo step. An error, with nothing changed, when no object is selected or given, when none has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges → {ids}", + "{ids?: [..] (default: the selected objects)} replace each object that has a visible 3D effect (Extrude & Bevel, Revolve, Inflate, Rotate or one of their 3D (Classic) forms, on the object or on its fills and strokes) with a group of paths: the edges of the faces the effect draws, as the object is turned and projected now, each edge once (edges that meet end to end make one path, closed where it comes back to its start; curved edges stay curves; geometry effects after the 3D effect, on the object or on a fill or stroke, reshape them too), stroked 0.5 pt black with round joins and no fill (VectorCraft's own); the group takes the object's place, id, name, opacity, blending mode, opacity mask, attributes, slice and text wrap and is selected (a layer stays a layer holding the edges, which are selected, and its ruler guides and paths made into guides; a path inside a compound path in ids stands for the compound path); objects without a visible 3D effect are left as they are; one undo step. An error, with nothing changed, when no object is selected or given, when none has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges → {ids}", has_doc, expand_as_wireframes )] @@ -68,7 +68,9 @@ fn expand_as_wireframes(s: &mut Session, p: &Value) -> Result<Value> { let mut wires = vec![]; let mut seen = std::collections::HashSet::new(); for id in roots { - // An id given twice is expanded once. + // A path inside a compound path stands for the compound path, which draws it. An id given + // twice is expanded once. + let id = super::path::compound_or_self(d, id); if !seen.insert(id) { continue; } diff --git a/crates/engine/src/cmd/wireframe/tests.rs b/crates/engine/src/cmd/wireframe/tests.rs index 33a30975d9..7d9265bdbc 100644 --- a/crates/engine/src/cmd/wireframe/tests.rs +++ b/crates/engine/src/cmd/wireframe/tests.rs @@ -212,6 +212,23 @@ fn a_painted_effect_after_a_fill_s_3d_effect_finds_the_document_s_swatches() { } } +/// A path inside a compound path in `ids` stands for the compound path: the compound path becomes +/// the group, and no member is replaced by a group inside it. +#[test] +fn a_compound_path_member_in_ids_expands_the_compound_path() { + let (mut s, a) = setup(None); + let b = NodeId(s.execute("path.create", &json!({"d":"M250 100L350 100L350 200L250 200Z"})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids":[a.0, b.0]})).unwrap(); + let c = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"ids":[c.0],"effect":"threeD.extrude","item":null,"params":{}})).unwrap(); + let member = s.doc().unwrap().doc.node(c).unwrap().children().unwrap()[0].id; + let out = run(&mut s, json!({"ids":[member.0]})).unwrap(); + assert_eq!(out["ids"], json!([c.0])); + let st = s.doc().unwrap(); + let NodeKind::Group { children, clip: false } = &st.doc.node(c).unwrap().kind else { panic!("the compound path is a group") }; + assert!(!children.is_empty() && children.iter().all(|ch| ch.path_data().is_some())); +} + #[test] fn bad_ids_are_errors() { let (mut s, _) = setup(Some("threeD.extrude")); diff --git a/crates/ui-egui/src/panels/tests_three_d.rs b/crates/ui-egui/src/panels/tests_three_d.rs index e8bfede3fd..29751dd5d7 100644 --- a/crates/ui-egui/src/panels/tests_three_d.rs +++ b/crates/ui-egui/src/panels/tests_three_d.rs @@ -427,6 +427,22 @@ fn expand_as_wireframes_replaces_the_object_with_its_edges() { assert_eq!(undo_len(&app), history); } +/// The Quick Actions show on the Object tab of the effects that have one (Revolve (Classic) +/// included) and not on their Rotation tab; Revolve and Rotate show them on their first tab. +#[test] +fn quick_actions_show_on_the_object_tab_only() { + use crate::state::Dialog; + use vectorcraft_effects::{CLASSIC_EXTRUDE, CLASSIC_REVOLVE, EXTRUDE, INFLATE, REVOLVE}; + for effect in [EXTRUDE, INFLATE, CLASSIC_EXTRUDE, CLASSIC_REVOLVE] { + for (tab, shown) in [("object", true), ("rotation", false)] { + let d = Dialog::new("x", json!({"__effect": effect, "__tab": tab, "__gizmo": "rotation"})); + assert_eq!(super::quick_actions_shown(&d), shown, "{effect} {tab}"); + } + } + let d = Dialog::new("x", json!({"__effect": REVOLVE, "__tab": "rotation", "__gizmo": "rotation"})); + assert!(super::quick_actions_shown(&d)); +} + #[test] fn changed_settings_lists_only_new_values() { let before = json!({"depth": 50.0, "cap": true, "bevel": "none"}); diff --git a/crates/ui-egui/src/panels/three_d.rs b/crates/ui-egui/src/panels/three_d.rs index 5d4763090e..c9f2f2205e 100644 --- a/crates/ui-egui/src/panels/three_d.rs +++ b/crates/ui-egui/src/panels/three_d.rs @@ -7,7 +7,7 @@ use egui::Ui; use serde_json::{Map, Value, json}; use vectorcraft_doc::{Effect, Node}; -use vectorcraft_effects::{CLASSIC_EXTRUDE, EXTRUDE, INFLATE, THREE_D_TYPES, is_three_d}; +use vectorcraft_effects::{CLASSIC_EXTRUDE, CLASSIC_REVOLVE, EXTRUDE, INFLATE, THREE_D_TYPES, is_three_d}; use super::{live_run, pstate, set_pstate}; use crate::VectorcraftApp; @@ -261,7 +261,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { /// the first tab of the others (Revolve, Rotate), never on Lighting. fn quick_actions_shown(d: &Dialog) -> bool { let effect = d.str("__effect"); - let object_tab = [EXTRUDE, INFLATE, CLASSIC_EXTRUDE].contains(&effect.as_str()); + let object_tab = [EXTRUDE, INFLATE, CLASSIC_EXTRUDE, CLASSIC_REVOLVE].contains(&effect.as_str()); !crate::dialogs::revolve_gizmo::lighting(d) && (!object_tab || d.str("__tab") != "rotation") } diff --git a/docs/mcp.md b/docs/mcp.md index 7d344068bb..95de49525c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3237,7 +3237,7 @@ on the object or on a fill or stroke, reshape its edges as they reshape the draw black stroke with round joins and no fill (VectorCraft's own choice). The group keeps the object's place, id, name, visibility, lock, opacity, blending mode, knockout, opacity mask, attributes, slice and text wrap, and the groups are selected. -A layer in `ids` stays a layer: its members are replaced by the edges, which are selected, and its ruler guides and paths made into guides stay. Objects without a visible 3D effect do not change. It is one undo step and +A layer in `ids` stays a layer: its members are replaced by the edges, which are selected, and its ruler guides and paths made into guides stay. A path inside a compound path in `ids` stands for the compound path. Objects without a visible 3D effect do not change. It is one undo step and returns `{ids}`, the groups. It is an error, and nothing changes, when nothing is selected or given, when no object has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges. From eee6fb51fdc0f2605ecf08c53685c0e7dfe5bc81 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 11:39:53 -0800 Subject: [PATCH 83/92] Image Trace: sepia, single-hue and duotone photos are smoothed as gray photos are Color and Grayscale traces smoothed a gray photo before choosing its colors, but a tinted photo with the same tones skipped smoothing: the 0.065 error rule applied only to pictures whose channels stay within 8 levels of each other. A picture whose opaque pixels lie, 19 in 20, within 6 levels of one tone curve now takes the same rule. The curve runs through the median color of each 8-level stretch along the colors' main direction, and a pixel on it must also lie within 6 levels of the plane the colors spread along most, so art in several unrelated colors is not counted. The sixteen-versus-eight exception still keeps clean flat art on the flat-art route. --- crates/trace/src/quantize/cut_edge.rs | 125 +++++++++++++++++++- crates/trace/src/tests.rs | 161 ++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 281 insertions(+), 6 deletions(-) diff --git a/crates/trace/src/quantize/cut_edge.rs b/crates/trace/src/quantize/cut_edge.rs index a9f782b9bd..22e5c02af2 100644 --- a/crates/trace/src/quantize/cut_edge.rs +++ b/crates/trace/src/quantize/cut_edge.rs @@ -73,11 +73,13 @@ pub(super) fn settled(mut q: Quantized, img: &Raster, own: &[bool]) -> Quantized /// A gray picture's tones lie along one line instead of through the whole color cube, and eight /// colors come nearer to them. A neutral picture (19 in 20 of its opaque pixels with their /// channels within 8 levels of each other) is therefore also continuous tone when eight colors -/// stay more than 0.065 as far as a single color does, unless sixteen colors stay at most a fifth -/// as far as eight do. That exception holds for gray flat art of up to sixteen clean levels, where -/// sixteen colors come within about a level of every pixel. On gray photos sixteen colors stay -/// 0.18 to 0.48 as far as eight do, and on most anti-aliased or noisy gray flat art 0.24 to 0.5 -/// as far, so the exception holds for neither. +/// stay more than 0.065 as far as a single color does. So is a sepia, single-hue or duotone +/// picture whose tones follow one curve through the color cube (19 in 20 of its opaque pixels on +/// it, see [`on_one_curve`]). Neither is when sixteen colors stay at most a fifth as far as eight +/// do. That exception holds for flat art of up to sixteen clean levels, gray or tinted, where +/// sixteen colors come within about a level of every pixel. On gray and tinted photos sixteen +/// colors stay 0.18 to 0.57 as far as eight do, and on most anti-aliased or noisy gray flat art +/// 0.24 to 0.5 as far, so the exception holds for neither. pub(crate) fn continuous_tone(img: &Raster) -> bool { let hk = HistKey::new(img); let mut bins = vec![Bin::default(); 1 << 15]; @@ -108,7 +110,118 @@ pub(crate) fn continuous_tone(img: &Raster) -> bool { return false; } let one = unsaid(1); - eight > 0.1 * one || (20 * neutral >= 19 * opaque && eight > 0.065 * one && unsaid(16) > 0.2 * eight) + if eight > 0.1 * one { + return true; + } + eight > 0.065 * one && unsaid(16) > 0.2 * eight && (20 * neutral >= 19 * opaque || 20 * on_one_curve(img) >= 19 * opaque) +} + +/// How far (levels) a pixel may lie from the tone curve of its picture and still count as on it, +/// for [`on_one_curve`]. +const CURVE_OFF: f64 = 6.0; + +/// How long (levels) each stretch along a picture's main color direction is whose median color is a +/// point of its tone curve, for [`on_one_curve`]. +const CURVE_STEP: f64 = 8.0; + +/// How far (levels) the colors of a picture can lie from their mean along any direction: more than +/// the length of the color cube's diagonal (255 × √3). +const CURVE_REACH: f64 = 448.0; + +/// How many of the opaque pixels (an alpha of 128 or more) of `img` lie on its tone curve: within +/// [`CURVE_OFF`] of it, measured across its main color direction, and within [`CURVE_OFF`] of the +/// plane its colors spread along most. The main color direction is the direction through the color +/// cube along which the opaque pixels' colors spread the most. The tone curve goes through the +/// median color (the median of each channel) of the opaque pixels in each stretch of [`CURVE_STEP`] +/// levels along that direction, which a few pixels of another color in the stretch do not move. The +/// colors of a sepia, single-hue or duotone photo follow one such curve, as a gray photo's lie along +/// the line of grays, while a color photo's spread across it. A curve with a bend, as a duotone's, +/// stays within the plane, while art in several unrelated colors has colors off it. None are on +/// the curve when there is no main color direction (no opaque pixels, or all of them one color). +fn on_one_curve(img: &Raster) -> u64 { + let opaque = || img.rgba.as_chunks::<4>().0.iter().filter(|p| p[3] >= 128).map(|p| [p[0], p[1], p[2]].map(f64::from)); + let (mut n, mut sum, mut products) = (0.0, [0.0; 3], [[0.0; 3]; 3]); + for c in opaque() { + n += 1.0; + for i in 0..3 { + sum[i] += c[i]; + for j in 0..3 { + products[i][j] += c[i] * c[j]; + } + } + } + if n == 0.0 { + return 0; + } + let mean = sum.map(|s| s / n); + let covariance: [[f64; 3]; 3] = std::array::from_fn(|i| std::array::from_fn(|j| products[i][j] / n - mean[i] * mean[j])); + let Some(axis) = main_direction(&covariance) else { return 0 }; + let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + // The direction across the main one along which the colors spread the most, and the direction + // across both: off the plane the colors spread along most. None when they lie on one line. + let first = dot(covariance.map(|row| dot(row, axis)), axis); + let rest: [[f64; 3]; 3] = std::array::from_fn(|i| std::array::from_fn(|j| covariance[i][j] - first * axis[i] * axis[j])); + let off_plane = main_direction(&rest).and_then(|b| { + let n = [axis[1] * b[2] - axis[2] * b[1], axis[2] * b[0] - axis[0] * b[2], axis[0] * b[1] - axis[1] * b[0]]; + let len = dot(n, n).sqrt(); + (len > 1e-9).then(|| n.map(|v| v / len)) + }); + let stretches = (2.0 * CURVE_REACH / CURVE_STEP) as usize; + let stretch = |c: [f64; 3]| { + let along = dot([0, 1, 2].map(|k| c[k] - mean[k]), axis); + (((along + CURVE_REACH) / CURVE_STEP).floor().max(0.0) as usize).min(stretches - 1) + }; + // How many pixels of each stretch have each level in each channel. + let mut levels = vec![[[0u32; 256]; 3]; stretches]; + for p in img.rgba.as_chunks::<4>().0.iter().filter(|p| p[3] >= 128) { + if let Some(l) = levels.get_mut(stretch([p[0], p[1], p[2]].map(f64::from))) { + for (counts, &v) in l.iter_mut().zip(p) { + counts[usize::from(v)] += 1; + } + } + } + let middles: Vec<[f64; 3]> = levels + .iter() + .map(|l| { + l.map(|counts| { + let half = counts.iter().map(|&c| u64::from(c)).sum::<u64>().div_ceil(2); + let mut seen = 0u64; + counts + .iter() + .position(|&c| { + seen += u64::from(c); + seen >= half && seen > 0 + }) + .map_or(0.0, |v| v as f64) + }) + }) + .collect(); + opaque() + .filter(|&c| { + let Some(mid) = middles.get(stretch(c)) else { return false }; + let d = [0, 1, 2].map(|k| c[k] - mid[k]); + let along = dot(d, axis); + let off = off_plane.map_or(0.0, |o| dot([0, 1, 2].map(|k| c[k] - mean[k]), o)); + dot(d, d) - along * along <= CURVE_OFF * CURVE_OFF && off.abs() <= CURVE_OFF + }) + .count() as u64 +} + +/// The unit direction of the largest spread of a covariance matrix `m` (its main eigenvector), +/// found in a fixed 64 steps of power iteration from its longest row. None when it has no spread. +fn main_direction(m: &[[f64; 3]; 3]) -> Option<[f64; 3]> { + let norm = |v: [f64; 3]| (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + let mut v = *m.iter().max_by(|a, b| norm(**a).total_cmp(&norm(**b)))?; + for _ in 0..64 { + let len = norm(v); + if len <= 1e-9 { + return None; + } + let unit = v.map(|x| x / len); + v = [0, 1, 2].map(|i| (0..3).map(|j| m[i][j] * unit[j]).sum()); + } + let len = norm(v); + (len > 1e-9).then(|| v.map(|x| x / len)) } /// Drop unused palette entries and merge duplicates. diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 34192c8b43..f6176cdd6f 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1456,6 +1456,150 @@ fn a_gray_photo_is_continuous_tone_as_its_color_original_is() { } } +/// Sepia: dark brown to cream. +const SEPIA: [[f64; 3]; 2] = [[45.0, 28.0, 15.0], [250.0, 235.0, 205.0]]; +/// A single blue hue: navy to pale blue. +const BLUE: [[f64; 3]; 2] = [[12.0, 22.0, 55.0], [205.0, 225.0, 255.0]]; +/// A duotone: near black to orange to cream, two lines meeting at the orange. +const DUOTONE: [[f64; 3]; 3] = [[22.0, 18.0, 30.0], [205.0, 105.0, 35.0], [252.0, 238.0, 215.0]]; +/// The gray ramp. +const GRAY_RAMP: [[f64; 3]; 2] = [[0.0; 3], [255.0; 3]]; + +/// `img`'s grays (its red channel) as the colors of the ramp through `stops`, black going to the +/// first stop and white to the last, with `chroma` levels of noise per channel. Alpha is kept. +fn tinted(img: &Raster, stops: &[[f64; 3]], chroma: f64) -> Raster { + Raster::from_fn(img.width, img.height, |x, y| { + let i = ((y * img.width + x) * 4) as usize; + let g = f64::from(img.rgba[i]) / 255.0 * (stops.len() - 1) as f64; + let s = (g.floor() as usize).min(stops.len() - 2); + let t = g - s as f64; + let c = [0, 1, 2] + .map(|k| (stops[s][k] + t * (stops[s + 1][k] - stops[s][k]) + chroma * jitter(x, y, 7 + k as u32)).round().clamp(0.0, 255.0) as u8); + [c[0], c[1], c[2], img.rgba[i + 3]] + }) +} + +/// [`three_colour_art`] as a gray scan: its grays with `noise` levels of the same noise on every +/// channel. +fn gray_scan_of_flat_art(noise: f64) -> Raster { + let clean = three_colour_art(0.0); + Raster::from_fn(clean.width, clean.height, |x, y| { + let i = ((y * clean.width + x) * 4) as usize; + let g = f64::from(quantize::luma([clean.rgba[i], clean.rgba[i + 1], clean.rgba[i + 2], 255])); + let v = (g + noise * jitter(x, y, 0)).round().clamp(0.0, 255.0) as u8; + [v, v, v, 255] + }) +} + +/// `levels` clean gray bands, evenly spaced from black to white. +fn gray_bands(levels: u32) -> Raster { + Raster::from_fn(16 * 20, 60, |x, _| { + let v = ((x / 20).min(levels - 1) * 255 / (levels - 1)) as u8; + [v, v, v, 255] + }) +} + +/// `img` on the left of a picture three times as wide whose other pixels are transparent and of +/// every color. +fn beside_transparent_colors(img: &Raster) -> Raster { + Raster::from_fn(img.width * 3, img.height, |x, y| { + if x < img.width { + let i = ((y * img.width + x) * 4) as usize; + [img.rgba[i], img.rgba[i + 1], img.rgba[i + 2], img.rgba[i + 3]] + } else { + let c = |k: u32| (127.5 + 127.5 * jitter(x, y, 20 + k)) as u8; + [c(0), c(1), c(2), 0] + } + }) +} + +#[test] +fn a_tinted_photo_is_continuous_tone_as_a_gray_photo_is() { + // With 0 to 4 levels of noise per channel (to 2 for the duotone). + let tints = [("sepia", &SEPIA[..], 4.0), ("blue", &BLUE[..], 4.0), ("duotone", &DUOTONE[..], 2.0)]; + for noise in [4.0, 6.0] { + let gray = photo_scene(noise, true); + assert!(quantize::continuous_tone(&gray), "gray, noise {noise}"); + assert!(quantize::continuous_tone(&photo_scene(noise, false)), "color, noise {noise}"); + for (name, stops, most) in tints { + for chroma in [0.0, 2.0, 4.0].into_iter().filter(|&c| c <= most) { + let photo = tinted(&gray, stops, chroma); + assert!(quantize::continuous_tone(&photo), "{name}, noise {noise}, chroma noise {chroma}"); + // Transparent pixels, whatever their colors, leave it as it is. + assert!(quantize::continuous_tone(&beside_transparent_colors(&photo)), "{name} beside transparent colors, noise {noise}"); + } + } + } +} + +#[test] +fn small_accents_of_other_colors_leave_a_tinted_photo_continuous_tone() { + // A sepia photo with a red and a green patch, each 2 in 100 of its pixels. + for noise in [4.0, 6.0] { + let photo = tinted(&photo_scene(noise, true), &SEPIA, 2.0); + let accented = Raster::from_fn(photo.width, photo.height, |x, y| match (x, y) { + (0..20, 0..20) => [220, 30, 40, 255], + (140..160, 108..128) => [40, 200, 60, 255], + _ => { + let i = ((y * photo.width + x) * 4) as usize; + [photo.rgba[i], photo.rgba[i + 1], photo.rgba[i + 2], 255] + } + }); + assert!(quantize::continuous_tone(&accented), "noise {noise}"); + // One patch, 4 in 100 of its pixels, in a color beside the middle of the ramp. + let beside = Raster::from_fn(photo.width, photo.height, |x, y| match (x, y) { + (0..32, 0..26) => [176, 104, 110, 255], + _ => { + let i = ((y * photo.width + x) * 4) as usize; + [photo.rgba[i], photo.rgba[i + 1], photo.rgba[i + 2], 255] + } + }); + assert!(quantize::continuous_tone(&beside), "noise {noise}, one patch"); + } +} + +#[test] +fn tinted_flat_art_stays_flat_art() { + let tints: [(&str, &[[f64; 3]]); 4] = [("gray", &GRAY_RAMP), ("sepia", &SEPIA), ("blue", &BLUE), ("duotone", &DUOTONE)]; + for (name, stops) in tints { + // Clean bands, nine of them included: more than eight colors can hold. + for levels in [2, 3, 9] { + let bands = tinted(&gray_bands(levels), stops, 0.0); + assert!(!quantize::continuous_tone(&bands), "{levels} {name} bands"); + assert!(!quantize::continuous_tone(&beside_transparent_colors(&bands)), "{levels} {name} bands beside transparent colors"); + } + // Scans of flat art, with noise on the tone and on each channel. + for noise in [0.0, 8.0] { + for chroma in [0.0, 4.0] { + assert!( + !quantize::continuous_tone(&tinted(&gray_scan_of_flat_art(noise), stops, chroma)), + "{name} scan, noise {noise}, chroma noise {chroma}" + ); + } + } + } + // As with gray (see `a_gray_photo_is_continuous_tone_as_its_color_original_is`), a single-hue + // scan stays flat art with up to 16 levels of noise on its tone. + for stops in [&GRAY_RAMP, &SEPIA, &BLUE] { + assert!(!quantize::continuous_tone(&tinted(&gray_scan_of_flat_art(16.0), stops, 0.0)), "{stops:?} scan, noise 16"); + } +} + +#[test] +fn pictures_without_tones_are_not_continuous_tone() { + let photo = tinted(&photo_scene(6.0, true), &SEPIA, 2.0); + let none = Raster::from_fn(0, 0, |_, _| [0, 0, 0, 0]); + let transparent = Raster::from_fn(photo.width, photo.height, |x, y| { + let i = ((y * photo.width + x) * 4) as usize; + [photo.rgba[i], photo.rgba[i + 1], photo.rgba[i + 2], 0] + }); + let one_pixel = Raster::from_fn(1, 1, |_, _| [200, 150, 100, 255]); + let one_color = Raster::from_fn(64, 64, |_, _| [200, 150, 100, 255]); + for (name, img) in [("empty", none), ("transparent", transparent), ("one pixel", one_pixel), ("one color", one_color)] { + assert!(!quantize::continuous_tone(&img), "{name}"); + } +} + #[test] fn a_gray_photo_is_smoothed_before_it_is_traced() { // As a color photo is. Traced with its noise, the noise mixes neighboring tones into one, @@ -1467,6 +1611,23 @@ fn a_gray_photo_is_smoothed_before_it_is_traced() { } } +#[test] +fn a_tinted_photo_is_smoothed_before_it_is_traced() { + // As a gray photo is, in Color and in Grayscale. Traced with its noise, the noise mixes + // neighboring tones into one, leaving 6 to 13 of the sixteen colors, and steps the edges + // between them (up to 1,402 anchors). + for noise in [4.0, 6.0] { + for (name, stops) in [("sepia", &SEPIA[..]), ("blue", &BLUE[..]), ("duotone", &DUOTONE[..])] { + let photo = tinted(&photo_scene(noise, true), stops, 2.0); + for mode in [Mode::Color, Mode::Grayscale] { + let res = trace(&photo, &TraceParams { mode, ..sixteen_colors() }); + assert!(res.palette.len() >= 12, "{name}, noise {noise}, {mode:?}: {} colors", res.palette.len()); + assert!(res.anchor_count() <= 1000, "{name}, noise {noise}, {mode:?}: {} anchors", res.anchor_count()); + } + } + } +} + #[test] fn stacked_layers_go_by_lightness_and_leave_ignored_white_alone() { // Stripes: black, white, mid gray, light gray (the widest). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5ec97e34c1..fb4fd0d7f3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1257,6 +1257,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, thin closed outlines:** generated `fit::snap_area_tests` verify nonzero interiors after straightening, opposite closed-boundary winding and Noise controls; see [quality notes](image-trace-quality.md#thin-closed-outlines-with-snap-curves-to-lines). Scores and hours unchanged; open-piece fitting is unchanged. - **Image Trace, curve fitting tolerance:** the outline fitter keeps cutting a run while a point stays farther than the tolerance from its curve, so a run of fewer than six points or one cut 14 times no longer keeps a piece over the tolerance. Pieces already within the tolerance do not change, and noisy art can get more anchors. Tests: `logo::bezier::tests`, `logo::outline::tests`. Scores and hours unchanged. - **Image Trace, gray photos:** Color and Grayscale smooth a gray photo within its tones before choosing its colors, as they do a color photo. A picture with at least 95% of its opaque pixels within 8 levels across their channels counts as continuous tone when 8 colors leave an error of more than 6 levels and more than 0.065 of the error of one color, instead of 0.1, unless 16 colors leave at most a fifth of the error of 8 (VectorCraft's own values). That exception keeps gray flat art of up to 16 clean levels on its old route; anti-aliased or noisy gray flat art can now count as continuous tone too. Most gray photos the change reaches, and very noisy gray scans of flat art, trace with fewer specks and fewer staircase edges. Some 16 Colors and High Fidelity Photo palettes of gray photos grow toward the requested count, with more anchors and regions. Some gray illustrations and sketches get more anchors and staircase edges at 16 Colors and High Fidelity Photo, and some 6 Colors traces get more staircase edges. Tests: `tests::a_gray_photo_is_continuous_tone_as_its_color_original_is`, `tests::a_gray_photo_is_smoothed_before_it_is_traced`. Scores and hours unchanged. +- **Image Trace, tinted photos:** Color and Grayscale smooth a sepia, single-hue or duotone photo within its tones before choosing its colors, as they do a gray photo. A picture with at least 95% of its opaque pixels within 6 levels of its tone curve counts as continuous tone under the same 0.065 rule as a neutral picture. The tone curve runs through the median color of each 8-level stretch along the direction in which the picture's colors spread the most, and a pixel on it must also lie within 6 levels of the plane the colors spread along most, so art in several unrelated colors is not counted (VectorCraft's own rule and values). The neutral rule's exception for 16 colors leaving at most a fifth of the error of 8 applies to it too. Clean tinted bands of 2, 3 and 9 levels and tinted scans of flat art with up to 8 levels of noise stay on the flat-art route. On the synthetic tinted photos the 16 Colors trace keeps 12 to 15 colors instead of 6 to 13, with 525 to 708 anchors instead of up to 1,402. A duotone with strong noise on each channel can still miss the curve and stay unsmoothed. Tests: `tests::a_tinted_photo_is_continuous_tone_as_a_gray_photo_is`, `tests::small_accents_of_other_colors_leave_a_tinted_photo_continuous_tone`, `tests::tinted_flat_art_stays_flat_art`, `tests::pictures_without_tones_are_not_continuous_tone`, `tests::a_tinted_photo_is_smoothed_before_it_is_traced`. Scores and hours unchanged. - **Image Trace, Document Library color groups:** with Document Swatches, the panel's Colors menu and `"colorGroup"` trace with one color group's colors instead of every swatch, as the public panel documentation describes Document Library; code-drawn tests in `crates/engine` and the panel. - **Image Trace, Document Library colors:** with Document Swatches, traced fills and strokes use the displayed colors of process, global, spot and tint swatches without links, so editing a swatch leaves the trace unchanged; swatch-library colors are also unlinked; code-drawn tests in `crates/engine`. - **Image Trace, flattened slivers:** in a Color, Grayscale or Black and White trace with Fills, a traced region whose outline straightens onto one line (a single pixel, or a one-pixel row along an edge, its pieces shared with the regions beside it) is left out instead of becoming a selectable path with no area, which paints nothing. Snap Curves To Lines straightens more outlines, so it leaves out more of these. Without Fills, the region's 1 px stroke draws it, and it is kept. Code-drawn tests in `crates/trace`. From 7c4414fed71af798deafd5a613caaf1d1a96936e Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 11:44:48 -0800 Subject: [PATCH 84/92] Learn: a located tool's toolbar comes to the front and a tall flyout scrolls - A Learn link that locates a tool on a toolbar made with New Toolbar brings that toolbar to the front once before its button is marked, so another floating toolbar no longer covers the mark. A group torn off into a strip still brings the strip to the front instead. - A docked flyout whose rows are taller than the window scrolls them, and a Learn link scrolls the located row into view before marking it. The mark is clipped to the rows' view. --- crates/ui-egui/src/custom_toolbars.rs | 11 ++++ crates/ui-egui/src/panels/learn/locate.rs | 15 ++++- crates/ui-egui/src/toolbar.rs | 55 +++++++++++++++--- crates/ui-egui/src/toolbar/tests.rs | 71 +++++++++++++++++++++++ 4 files changed, 143 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/custom_toolbars.rs b/crates/ui-egui/src/custom_toolbars.rs index 077b89efe7..f356f18b68 100644 --- a/crates/ui-egui/src/custom_toolbars.rs +++ b/crates/ui-egui/src/custom_toolbars.rs @@ -601,6 +601,17 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { // A Learn link locating a tool of this toolbar: its button, scrolled into view once. let locate = crate::panels::learn::locate::wanted(&app.ui, Some(&bar.name)); let scrolled = app.ui.learn_locate.as_ref().is_some_and(|l| l.scrolled); + // The toolbar comes to the front once, over other floating toolbars, unless the tool's + // group is torn off into a strip, which comes to the front instead. + let holder: Option<Vec<&'static str>> = locate + .and_then(|tool| bar.slots.iter().find(|s| s.iter().any(|t| t == tool))) + .map(|slot| slot.iter().filter_map(|id| tool_info(id).map(|t| t.id)).collect()); + let torn_off = holder + .as_ref() + .is_some_and(|tools| tools.len() > 1 && tools.first().is_some_and(|key| crate::toolbar::is_floating(app, Some(&bar.name), key))); + if holder.is_some() && !torn_off && crate::panels::learn::locate::raise_once(&mut app.ui) { + ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id)); + } let (mut found, mut did_scroll) = (None, false); egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| { crate::toolbar::flyout_frame(&t, false).inner_margin(egui::Margin { left: 0, right: 0, top: 0, bottom: 4 }).show(ui, |ui| { diff --git a/crates/ui-egui/src/panels/learn/locate.rs b/crates/ui-egui/src/panels/learn/locate.rs index 787e1677eb..607da1c45c 100644 --- a/crates/ui-egui/src/panels/learn/locate.rs +++ b/crates/ui-egui/src/panels/learn/locate.rs @@ -42,6 +42,8 @@ pub struct Locate { pub(crate) scrolled: bool, /// The group's flyout was opened or its strip raised. pub(crate) revealed: bool, + /// The toolbar made with New Toolbar that holds the tool was brought to the front. + raised: bool, /// The mark drew in this pass. drawn: bool, /// Passes in a row in which the mark did not draw. @@ -76,6 +78,17 @@ pub(crate) fn reveal_once(ui: &mut UiState) -> bool { ui.learn_locate.as_mut().is_some_and(|l| !std::mem::replace(&mut l.revealed, true)) } +/// Whether the toolbar made with New Toolbar that holds the tool is still to be brought to the +/// front; true once. +pub(crate) fn raise_once(ui: &mut UiState) -> bool { + ui.learn_locate.as_mut().is_some_and(|l| !std::mem::replace(&mut l.raised, true)) +} + +/// Whether a locate is under way and its mark has not drawn yet. +pub(crate) fn unmarked(ui: &UiState) -> bool { + ui.learn_locate.as_ref().is_some_and(|l| l.since.is_none()) +} + /// Mark `rect`, the located tool's control as drawn this pass on `layer`, clipped to `clip` and the /// window. The first mark starts the [`LIFETIME`]; once it has passed, the locate ends. pub(crate) fn mark(app: &mut VectorcraftApp, ctx: &Context, layer: LayerId, clip: Rect, rect: Rect) { @@ -129,7 +142,7 @@ fn start(app: &mut VectorcraftApp, tool: &'static str, target: Target) { app.ui.tools_drawer = false; } } - app.ui.learn_locate = Some(Locate { tool, target, since: None, scrolled: false, revealed: false, drawn: false, unseen: 0 }); + app.ui.learn_locate = Some(Locate { tool, target, since: None, scrolled: false, revealed: false, raised: false, drawn: false, unseen: 0 }); } /// `learn.locateTool {tool}`: mark where `tool` is in the current layout → diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index fc36d8cf41..8f2d2395fb 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -747,14 +747,27 @@ struct FlyoutInput { close: bool, /// Where the tool a Learn link locates is drawn. located: Option<egui::Rect>, + /// What a docked flyout's rows are clipped to (they scroll in a group taller than the window). + located_clip: Option<egui::Rect>, } /// A flyout's tool rows (a floating flyout's strip of tool buttons) with the grab bar down their /// right side; a floating flyout's bar has a × at its top. The bar's id is the group's, so a drag /// that tears a flyout off carries on moving the floating flyout it becomes (`toolbar`: the toolbar /// made with New Toolbar the group is in, none for the Tools panel's; `locate`: the tool a Learn -/// link locates, whose place it returns). -fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating: bool, toolbar: Option<&str>, locate: Option<&str>) -> FlyoutInput { +/// link locates, whose place it returns; `reveal_row`: a located row outside the flyout's view +/// scrolls into it, and is returned once it shows). +#[allow(clippy::too_many_arguments)] +fn flyout_body( + ui: &mut Ui, + t: &Tokens, + active: &str, + tools: &[String], + floating: bool, + toolbar: Option<&str>, + locate: Option<&str>, + reveal_row: bool, +) -> FlyoutInput { let mut out = FlyoutInput::default(); let key = tools.first().map_or("", String::as_str); let bar_id = bar_id(toolbar, key); @@ -763,7 +776,29 @@ fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating let rows = if floating { ui.horizontal(|ui| tool_strip(ui, t, active, tools, locate)) } else { - ui.vertical(|ui| tool_rows(ui, t, active, tools, locate)) + // A group taller than the window scrolls its rows (inside the frame's 1 pt border). The + // rows' height is given, since the row this sits in starts one widget tall. + let max = ui.ctx().content_rect().height() - 2.0; + let height = (tools.iter().filter(|id| tool_info(id).is_some()).count() as f32 * ROW_HEIGHT).min(max); + ui.vertical(|ui| { + egui::ScrollArea::vertical() + .id_salt(bar_id.with("rows")) + .max_height(max) + .min_scrolled_height(height) + .show(ui, |ui| { + let (chosen, located) = tool_rows(ui, t, active, tools, locate); + // The mark is clipped to the rows' view, not to the whole flyout. + out.located_clip = Some(ui.clip_rect()); + match located.filter(|r| reveal_row && !ui.clip_rect().contains_rect(*r)) { + Some(r) => { + ui.scroll_to_rect_animation(r, Some(egui::Align::Center), egui::style::ScrollAnimation::none()); + (chosen, None) + } + None => (chosen, located), + } + }) + .inner + }) }; (out.chosen, out.located) = rows.inner; let (bar, _) = ui.allocate_exact_size(vec2(BAR, rows.response.rect.height()), Sense::hover()); @@ -810,7 +845,7 @@ fn tool_rows(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], locate: Op let w = rows_width(rows.iter().map(|(_, text)| text)); for (tool, (name, shortcut)) in rows { let is_active = active == tool.id; - let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(w, ROW_HEIGHT), Sense::click()); if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover); } @@ -843,6 +878,8 @@ type RowText = (Arc<egui::Galley>, Option<Arc<egui::Galley>>); const NAME_X: f32 = 52.0; const NAME_GAP: f32 = 14.0; const ROW_END: f32 = 18.0; +/// The height of a flyout row. +const ROW_HEIGHT: f32 = 30.0; /// The name of `tool` in `lang` and its shortcut in brackets, as a flyout row draws them. fn row_text(ui: &Ui, lang: crate::i18n::Lang, tool: &ToolInfo, color: Color32) -> RowText { @@ -947,18 +984,20 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) { let active = app.session.tool_id(); let toolbar = ctx.data(|d| d.get_temp::<Option<String>>(flyout_toolbar_id())).flatten(); let locate = learn_locate::wanted(&app.ui, toolbar.as_deref()).filter(|tool| tools.iter().any(|id| id == tool)); + let reveal_row = learn_locate::unmarked(&app.ui); let mut input = FlyoutInput::default(); // Beside the tool, on the side with room for it: toward the canvas from a toolbar docked right. let size = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).map_or(vec2(160.0, 0.0), |r| r.size()); let x = beside(anchor, size.x, 8.0, room(app, ctx)); // The area stays inside the window (egui's `Area::constrain`, on by default): a flyout opened - // near the bottom moves up, so every row, a located one included, shows. + // near the bottom moves up, and a group taller than the window scrolls a located row into view. let resp = egui::Area::new(egui::Id::unique("tool-flyout")).order(egui::Order::Foreground).fixed_pos(pos2(x, anchor.top() - 1.0)).show(ctx, |ui| { - flyout_frame(&t, false).show(ui, |ui| input = flyout_body(ui, &t, active, &tools, false, toolbar.as_deref(), locate)); + flyout_frame(&t, false).show(ui, |ui| input = flyout_body(ui, &t, active, &tools, false, toolbar.as_deref(), locate, reveal_row)); }); if let Some(rect) = input.located { - learn_locate::mark(app, ctx, resp.response.layer_id, resp.response.rect, rect); + let clip = input.located_clip.map_or(resp.response.rect, |c| c.intersect(resp.response.rect)); + learn_locate::mark(app, ctx, resp.response.layer_id, clip, rect); } // Dragging the bar tears the flyout off, and so does releasing a press over it (a click, or the // end of the long press that opened the flyout): it floats where the pointer is. @@ -1065,7 +1104,7 @@ fn floating_strips(app: &mut VectorcraftApp, ctx: &egui::Context, toolbar: Optio let pos = crate::tools_float::clamp(f.pos, size, screen); let mut input = FlyoutInput::default(); let area = egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| { - flyout_frame(&t, flash).show(ui, |ui| input = flyout_body(ui, &t, active, &f.tools, true, toolbar, locate)); + flyout_frame(&t, flash).show(ui, |ui| input = flyout_body(ui, &t, active, &f.tools, true, toolbar, locate, false)); }); if let Some(rect) = input.located { located = Some((area.response.layer_id, area.response.rect, rect)); diff --git a/crates/ui-egui/src/toolbar/tests.rs b/crates/ui-egui/src/toolbar/tests.rs index 8ea1928130..9dec20b9dc 100644 --- a/crates/ui-egui/src/toolbar/tests.rs +++ b/crates/ui-egui/src/toolbar/tests.rs @@ -1301,6 +1301,77 @@ fn a_learn_link_in_a_short_window_marks_a_flyout_row_inside_the_window() { assert_marks(&marks, row, tool); } +/// A group with more rows than the window holds scrolls its flyout, and a Learn link scrolls the +/// located row into view before marking it. +#[test] +fn a_learn_link_scrolls_the_row_of_a_group_taller_than_the_window_into_view() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let tools: Vec<&'static str> = slots(&app).into_iter().filter_map(|(_, s)| s.first().copied()).skip(1).take(16).collect(); + for tool in &tools { + app.run("toolbar.addTool", json!({"tool": tool, "slot": 0, "place": "group"})).unwrap(); + } + let tool = *tools.last().expect("a tool"); + let group = slots(&app).into_iter().map(|(_, s)| s).find(|s| s.contains(&tool)).expect("the group"); + assert!(group.len() * 30 > 400, "{} rows of 30 points do not fit in 400", group.len()); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 400.0); + assert_eq!(app.run("learn.locateTool", json!({"tool": tool})).unwrap()["location"], "flyout", "{tool}"); + let mut drawn = (vec![], vec![]); + for k in 1..20 { + drawn = locate_frame(&mut app, &ctx, f64::from(k) * 0.1, vec![], 400.0); + } + let (marks, nodes) = drawn; + let row = tool_rect(&nodes, tool); + assert!(row.top() >= 0.0 && row.bottom() <= 400.0, "{tool}: the row is inside the window: {row:?}"); + assert_marks(&marks, row, tool); + assert_eq!(app.session.tool_id(), "selection"); + // The mark is clipped to the rows' view inside the flyout's 1 pt border, so a row scrolled + // part way out shows no mark over the border above or below the rows. + let view = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).expect("the flyout shows").shrink(1.0); + let screen_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0))); + let out = ctx.run_ui(egui::RawInput { time: Some(2.0), screen_rect, ..Default::default() }, |ui| show(&mut app, ui)); + let color = Tokens::get(&ctx).selection; + let clips: Vec<egui::Rect> = out + .shapes + .iter() + .filter(|c| matches!(&c.shape, egui::Shape::Rect(r) if r.stroke.width == MARK_WIDTH && r.stroke.color == color)) + .map(|c| c.clip_rect) + .collect(); + assert!( + !clips.is_empty() && clips.iter().all(|c| c.top() >= view.top() && c.bottom() <= view.bottom()), + "the mark is clipped to {view:?}: {clips:?}" + ); +} + +/// A toolbar made with New Toolbar under another floating toolbar comes to the front for a Learn +/// link, so its marked button shows. +#[test] +fn a_learn_link_brings_a_covered_toolbar_made_with_new_toolbar_to_the_front() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + for (name, tool) in [("Mine", "artboard"), ("Other", "slice")] { + app.run("window.toolbar.new", json!({"name": name})).unwrap(); + app.run("toolbar.addTool", json!({"tool": tool, "toolbar": name})).unwrap(); + } + for bar in &mut app.ui.custom_toolbars { + bar.pos = [200.0, 200.0]; + } + let (mine, other) = (crate::custom_toolbars::area_id("Mine"), crate::custom_toolbars::area_id("Other")); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 1200.0); + ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, other)); + let (_, nodes) = locate_frame(&mut app, &ctx, 0.05, vec![], 1200.0); + let button = tool_rect(&nodes, "artboard"); + assert_eq!(ctx.layer_id_at(button.center()).map(|l| l.id), Some(other), "Other covers the button of Mine"); + assert_eq!( + app.run("learn.locateTool", json!({"tool": "artboard"})).unwrap(), + json!({"tool": "artboard", "location": "toolbar", "toolbar": "Mine"}) + ); + let (marks, nodes) = locate_frame(&mut app, &ctx, 1.0, vec![], 1200.0); + let button = tool_rect(&nodes, "artboard"); + assert_eq!(ctx.layer_id_at(button.center()).map(|l| l.id), Some(mine), "Mine comes to the front"); + assert_marks(&marks, button, "the toolbar Mine"); +} + /// A second Learn link moves the mark; Escape removes it. #[test] fn a_second_learn_link_replaces_the_mark_and_escape_removes_it() { From 1f458a3ab01465e0558c444fb6fcda5334ca3b36 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 11:45:53 -0800 Subject: [PATCH 85/92] Transform: setBounds measures miter spikes of dashed strokes at a slant as drawn A miter join on a stroke whose dashes have round or projecting caps still paints the miter spikes the limit keeps, and the visual box includes them. object.setBounds with Use Preview Bounds did not count such a stroke as one whose margins follow its segments' directions, so a closed path at a slant missed the size typed when the width and height were out of proportion. --- crates/doc/src/reach.rs | 24 +++++++++++++++++------- crates/engine/src/tests/previewbounds.rs | 15 +++++++++++---- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs index 7f9b6a1766..4648f3087e 100644 --- a/crates/doc/src/reach.rs +++ b/crates/doc/src/reach.rs @@ -217,6 +217,14 @@ impl StrokeLayer { (self.cap == LineCap::Butt && !closed) || (self.join != LineJoin::Round && !capped_dashes) } + /// Does this stroke reach past a subpath that is `closed` or open by a margin that follows the + /// direction of its segments at their corners: where it stops at them + /// ([`Self::stops_at_corners`]) or paints miter spikes, which dashes with round or projecting + /// caps also paint? + fn reaches_by_corners(&self, closed: bool) -> bool { + self.stops_at_corners(closed) || self.join == LineJoin::Miter + } + /// The box this stroke's body paints along `segs`, the segments with length of a subpath, short /// of its caps, joined with `within`: each segment's [`swept_box`] and, with round joins on an /// open subpath, the arc each joint between segments paints ([`round_join_box`]), a shallow one @@ -506,16 +514,18 @@ impl Appearance { } /// Does this appearance have a visible stroke that stops at the corners of its segments on an - /// open subpath or a closed one: butt caps, or bevel and miter joins? + /// open subpath or a closed one, or paints miter spikes: butt caps, or bevel and miter joins + /// ([`StrokeLayer::reaches_by_corners`])? pub fn has_cornered_stroke(&self) -> bool { - self.painted_strokes().any(|s| s.stops_at_corners(false)) + self.painted_strokes().any(|s| s.reaches_by_corners(false)) } /// Does a visible stroke of this appearance that stops at the corners of its segments (butt - /// caps on an open subpath, bevel and miter joins) run along a subpath of `bp` where it does so, - /// with a segment that starts or ends at a slant, not square to the axes, once placed by `a`? - /// The visual box's margin at those corners, and at miter tips, depends on their direction, - /// which a scale out of proportion changes. + /// caps on an open subpath, bevel and miter joins) or paints miter spikes, dashed ones with + /// round or projecting caps included, run along a subpath of `bp` where it does so, with a + /// segment that starts or ends at a slant, not square to the axes, once placed by `a`? The + /// visual box's margin at those corners, and at miter tips, depends on their direction, which a + /// scale out of proportion changes. pub fn has_slanted_stroke_corners(&self, bp: &BezPath, a: vectorcraft_geom::Affine) -> bool { if !self.has_cornered_stroke() { return false; @@ -526,7 +536,7 @@ impl Appearance { }; let els = bp.elements(); let subs = subpaths(bp); - let cornered = |closed: bool| self.painted_strokes().any(|s| s.stops_at_corners(closed)); + let cornered = |closed: bool| self.painted_strokes().any(|s| s.reaches_by_corners(closed)); subs.into_iter() .filter(|(_, closed)| cornered(*closed)) .any(|(r, _)| drawn(&els[r]).iter().any(|s| slanted(tangent(s, 0.0)) || slanted(tangent(s, 1.0)))) diff --git a/crates/engine/src/tests/previewbounds.rs b/crates/engine/src/tests/previewbounds.rs index c0ce01cb57..e395c708bc 100644 --- a/crates/engine/src/tests/previewbounds.rs +++ b/crates/engine/src/tests/previewbounds.rs @@ -672,19 +672,26 @@ fn set_bounds_gives_a_butt_path_with_slanted_corners_the_size_out_of_proportion( fn set_bounds_gives_a_closed_path_with_beveled_or_mitered_corners_the_size_out_of_proportion() { // The bevel at the triangle's tip, the miter the limit bevels there and the miter spikes it // keeps reach past the path by margins that follow the slanted segments' directions, which a - // scale out of proportion changes. + // scale out of proportion changes. The miter spikes of a stroke whose dashes have round or + // projecting caps reach past the path too. let mut wrong = vec![]; - for (join, limit) in [("bevel", 10.0), ("miter", 2.0), ("miter", 10.0)] { + for (join, limit, cap, dash) in [ + ("bevel", 10.0, "round", Value::Null), + ("miter", 2.0, "round", Value::Null), + ("miter", 10.0, "round", Value::Null), + ("miter", 10.0, "round", json!([6, 3])), + ("miter", 10.0, "projecting", json!([6, 3])), + ] { let mut s = session(); let anchors = json!([{"x": 100, "y": 150}, {"x": 110, "y": 100}, {"x": 120, "y": 150}]); let id = NodeId(run(&mut s, "path.create", json!({"anchors": anchors, "closed": true}))["id"].as_u64().unwrap()); - run(&mut s, "stroke.set", json!({"weight": 2, "cap": "round", "join": join, "miterLimit": limit})); + run(&mut s, "stroke.set", json!({"weight": 2, "cap": cap, "join": join, "miterLimit": limit, "dash": dash})); preview(&mut s, true); run(&mut s, "select.set", json!({"ids": [id.0]})); run(&mut s, "object.setBounds", json!({"width": 60, "height": 20, "reference": 0, "strokes": false})); let v = s.transform_bounds(&[id]).unwrap(); if (v.width() - 60.0).abs() > 1e-6 || (v.height() - 20.0).abs() > 1e-6 { - wrong.push(format!("{join} {limit}: {v:?}")); + wrong.push(format!("{join} {limit} {cap} {dash}: {v:?}")); } } assert!(wrong.is_empty(), "{wrong:?}"); From 05cf130fde595b8e2f36480e518f6ea2937cc3ea Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 11:50:59 -0800 Subject: [PATCH 86/92] Document Info: Brushes lists the brushes applied to art, with their types Document Info's Brushes category now lists the brushes the artwork's strokes draw with, each once with its type (Calligraphic, Scatter, Art, Pattern or Bristle), as the Document Info panel help page describes: https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html Without Selection Only the category used to list every brush in the Brushes panel, used or not; it now lists only the brushes art uses. document.info adds `brushes` ([{name, type}]) and `panelBrushNames` (the Brushes panel's brushes), and `brushNames` holds the used brushes. Hidden strokes, strokes with no paint or width, guides, paths with no segment, type that draws no characters, groups' and layers' own strokes, compound path members' own strokes and image strokes without a shape are left out. A brush the panel does not hold is listed as missing, with a null type, and a name two panel brushes share takes the type of the brush the renderer draws with. --- crates/engine/src/cmd/docinfo.rs | 204 ++++++++++++++++++++++++++----- docs/mcp.md | 8 +- docs/target-app-parity.md | 2 +- 3 files changed, 180 insertions(+), 34 deletions(-) diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 4ed63e7ec1..d617fad088 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> { "Document Info", ["Window", "Document Info"], None, - "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone", + "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone", has_doc, info ), @@ -131,14 +131,16 @@ fn repeat_entry(d: &Document, n: &Node, spec: &RepeatSpec) -> (Value, (String, S } /// The Document Info categories of `d` (only `category` when given) from `info` (what -/// `document.info` reports), its fonts, its image objects, the rows of its Repeat objects and the -/// rows of its objects filled or stroked with a spot color, with a pattern and with a gradient. +/// `document.info` reports), its fonts, its image objects, the rows of the brushes its art uses, the +/// rows of its Repeat objects and the rows of its objects filled or stroked with a spot color, with a +/// pattern and with a gradient. #[allow(clippy::too_many_arguments)] fn sections( d: &Document, info: &Value, fonts: &BTreeSet<super::fonts::UsedFont>, images: &[ImageRow], + brushes: &[(String, String)], repeats: &[(String, String)], spot_objects: &[(String, String)], pattern_objects: &[(String, String)], @@ -184,7 +186,7 @@ fn sections( rows } "graphicStyles" => names("graphicStyleNames"), - "brushes" => names("brushNames"), + "brushes" => brushes.to_vec(), "spotColors" => spot_objects.to_vec(), "patterns" => pattern_objects.to_vec(), "gradients" => gradient_objects.to_vec(), @@ -326,12 +328,18 @@ fn painted_runs<'a>(d: &Document, spots: &HashSet<&str>, n: &'a Node) -> Option< if !own && !t.runs.iter().any(|r| is_listed(&r.style.fill) || is_listed(&r.style.stroke)) { return None; } + Some((t, drawn_runs(d, t))) +} + +/// The runs of type `t` that its layout draws: those with a glyph outline (list markers and ruby +/// included) or an underline or strikethrough bar, laid out as the renderer lays it out. +fn drawn_runs(d: &Document, t: &vectorcraft_doc::TextObject) -> BTreeSet<usize> { let db = vectorcraft_text::FontDb::global(); let resolved = d.inline_resolved(t); let lay = vectorcraft_text::layout(db, &resolved); let mut painted: BTreeSet<usize> = lay.glyphs.iter().chain(&lay.ruby).filter(|g| !g.outline.elements().is_empty()).map(|g| g.run).collect(); painted.extend(vectorcraft_text::decorations(&lay, db, &resolved).into_iter().map(|(run, _)| run)); - Some((t, painted)) + painted } /// The names a paint is listed by in a Document Info category: its spot colors, its pattern or its @@ -381,6 +389,41 @@ fn one_label(label: fn(&Paint) -> Option<String>) -> impl Fn(&Paint) -> Vec<Stri move |p| label(p).into_iter().collect() } +/// The brushes `n`'s own strokes draw with, in the order of its appearance. A hidden stroke, a +/// stroke with no paint and a stroke with no width draw nothing, and neither does any stroke of a +/// guide (drawn in the guide color) or of a compound path's member (`members`: the compound path's +/// own strokes draw it). An image's strokes draw only where an effect gives them a shape, and only +/// paths, compound paths, type and images draw strokes of their own. +fn applied_brushes(d: &Document, n: &Node, members: &HashSet<*const Node>) -> Vec<String> { + if matches!(n.kind, NodeKind::Path { guide: true, .. }) || members.contains(&std::ptr::from_ref(n)) { + return vec![]; + } + let brush = |item: &vectorcraft_doc::AppearanceItem| match item { + vectorcraft_doc::AppearanceItem::Stroke(st) if item.visible() && !st.paint.is_none() && st.width > 0.0 => { + st.brush.clone().filter(|b| !b.is_empty()) + } + _ => None, + }; + if matches!(n.kind, NodeKind::Image(_)) { + return vectorcraft_render::effects::image_paint(Some(d), n).iter().filter_map(|(_, _, item)| brush(item)).collect(); + } + let used: Vec<String> = n.appearance.items.iter().filter_map(brush).collect(); + if used.is_empty() { + return used; + } + // A path or compound path with no segment gives its strokes nothing to follow, and neither does + // type whose layout draws nothing (spaces and line breaks without an underline or strikethrough). + let segments = |pd: &vectorcraft_geom::PathData| pd.subpaths.iter().any(|sp| sp.segment_count() > 0); + let follows = match &n.kind { + NodeKind::Path { .. } => n.path_data().is_some_and(segments), + NodeKind::Compound { children, .. } => children.iter().filter_map(|c| c.path_data()).any(segments), + NodeKind::Text(t) => !drawn_runs(d, t).is_empty(), + // A brush on any other kind of object (a layer, a group, a ruler guide) is not listed. + _ => false, + }; + if follows { used } else { vec![] } +} + fn info(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "document.info"; let category = str_param(p, "category"); @@ -425,8 +468,10 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> { let mut spot = BTreeSet::new(); let (spot_label, pattern, gradient) = (|p: &Paint| spot_labels(&spot_swatches, p), one_label(pattern_label), one_label(gradient_label)); let mut styles: Vec<&str> = if selection_only { vec![] } else { d.graphic_styles.iter().map(|g| g.name.as_str()).collect() }; - // `brushes` holds the Brushes panel's brushes, or with selectionOnly it holds the brushes that the selected objects' strokes use. - let mut brushes: Vec<String> = if selection_only { vec![] } else { vectorcraft_brush::library(d).into_iter().map(|b| b.name).collect() }; + // `library` holds the Brushes panel's brushes, and `brushes` the brushes the visited objects' strokes draw with, each once, + // in walk order. + let library = vectorcraft_brush::library(d); + let mut brushes: Vec<String> = vec![]; // `repeats` and `repeat_rows` hold the `repeatObjects` entries and the rows of the Repeat objects that the walk visits, in walk order. let mut repeats: Vec<Value> = vec![]; let mut repeat_rows: Vec<(String, String)> = vec![]; @@ -569,14 +614,9 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> { { styles.push(&g.name); } - if selection_only { - for item in &n.appearance.items { - if let vectorcraft_doc::AppearanceItem::Stroke(st) = item - && let Some(b) = &st.brush - && !brushes.contains(b) - { - brushes.push(b.clone()); - } + for b in applied_brushes(d, n, &members) { + if !brushes.contains(&b) { + brushes.push(b); } } if let NodeKind::Repeat(spec) = &n.kind { @@ -587,6 +627,17 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> { }); } let font_names: Vec<String> = fonts.iter().map(super::fonts::font_label).collect(); + // Each brush the art uses with its type, found by name in the Brushes panel as the renderer finds it (the last of two + // brushes with one name); a brush the panel does not hold draws as a plain stroke and has no type. + let kinds: HashMap<&str, &vectorcraft_brush::BrushKind> = library.iter().map(|b| (b.name.as_str(), &b.kind)).collect(); + let brush_entries: Vec<Value> = + brushes.iter().map(|name| json!({ "name": name, "type": kinds.get(name.as_str()).map(|k| k.type_id()) })).collect(); + let brush_rows: Vec<(String, String)> = brushes + .iter() + .map(|name| { + (name.clone(), kinds.get(name.as_str()).map_or_else(|| "missing from the Brushes panel".to_string(), |k| format!("{} brush", k.label()))) + }) + .collect(); let mut out = json!({ "document": document_summary(d), "objects": counts, @@ -601,6 +652,8 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> { "graphicStyles": d.graphic_styles.len(), "graphicStyleNames": styles, "brushNames": brushes, + "brushes": brush_entries, + "panelBrushNames": library.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), "repeatObjects": repeats, "characterStyles": d.char_styles.len(), "paragraphStyles": d.para_styles.len(), @@ -612,7 +665,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> { }); // The document's name: its file's, else its title. out["document"]["name"] = json!(st.title()); - let sections = sections(d, &out, &fonts, &image_rows, &repeat_rows, &spot_rows, &pattern_rows, &gradient_rows, category); + let sections = sections(d, &out, &fonts, &image_rows, &brush_rows, &repeat_rows, &spot_rows, &pattern_rows, &gradient_rows, category); if text { return Ok(json!({ "text": report_text(&st.title(), selection_only, §ions) })); } @@ -725,38 +778,127 @@ mod tests { assert_eq!(length(&mut s, json!({"selectionOnly": true})), 0.0); } - /// Document Info › Brushes lists the Brushes panel's brushes, and with Selection Only it lists - /// the brushes the selected objects' strokes use, each once. + /// Document Info › Brushes lists the brushes the art's strokes use, each once with its type, and + /// leaves out the Brushes panel's brushes no art uses (`panelBrushNames` lists those). With + /// Selection Only it lists the brushes the selected objects' strokes use. #[test] fn info_lists_the_brushes() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); let panel: Vec<Value> = s.execute("brush.list", &json!({})).unwrap()["brushes"].as_array().unwrap().iter().map(|b| b["name"].clone()).collect(); - let i = s.execute("document.info", &json!({"category": "brushes"})).unwrap(); - assert_eq!(i["brushNames"], json!(panel)); + let brushes = |s: &mut Session, p: Value| s.execute("document.info", &p).unwrap(); + // No art uses a brush: the category is empty. + let i = brushes(&mut s, json!({"category": "brushes"})); + assert_eq!((i["brushNames"].clone(), i["brushes"].clone(), i["panelBrushNames"].clone()), (json!([]), json!([]), json!(panel))); assert_eq!((i["sections"][0]["id"].as_str(), i["sections"][0]["title"].as_str()), (Some("brushes"), Some("Brushes"))); - let rows: Vec<Value> = panel.iter().map(|n| json!([n, ""])).collect(); - assert_eq!(i["sections"][0]["rows"], json!(rows)); + assert_eq!(i["sections"][0]["rows"], json!([])); let a = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); let b = s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); let c = s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); s.execute("brush.apply", &json!({"ids": [a], "name": "Charcoal"})).unwrap(); - s.execute("brush.apply", &json!({"ids": [b], "name": "Arrow"})).unwrap(); + s.execute("brush.apply", &json!({"ids": [b], "name": "Dots"})).unwrap(); s.execute("brush.apply", &json!({"ids": [c], "name": "Charcoal"})).unwrap(); - let names = - |s: &mut Session| s.execute("document.info", &json!({"selectionOnly": true, "category": "brushes"})).unwrap()["brushNames"].clone(); + // The whole document: the two brushes the art uses, each once, with their types; "3 pt. Round" (a Calligraphic + // brush in the panel) is left out. + let i = brushes(&mut s, json!({"category": "brushes"})); + assert_eq!(i["brushNames"], json!(["Charcoal", "Dots"])); + assert_eq!(i["brushes"], json!([{"name": "Charcoal", "type": "art"}, {"name": "Dots", "type": "scatter"}])); + assert_eq!(i["sections"][0]["rows"], json!([["Charcoal", "Art brush"], ["Dots", "Scatter brush"]])); + assert!(panel.contains(&json!("3 pt. Round")) && i["panelBrushNames"] == json!(panel)); + let text = brushes(&mut s, json!({"category": "brushes", "format": "text"}))["text"].as_str().unwrap().to_string(); + assert!(text.contains("\nBRUSHES\nCharcoal: Art brush\nDots: Scatter brush\n") && !text.contains("3 pt. Round"), "{text}"); + assert!(!text.contains("OBJECTS"), "{text}"); + // Selection Only: the brushes the selected objects' strokes use. + let selected = |s: &mut Session| brushes(s, json!({"selectionOnly": true, "category": "brushes"})); s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap(); - assert_eq!(names(&mut s), json!(["Charcoal", "Arrow"])); + assert_eq!(selected(&mut s)["brushNames"], json!(["Charcoal", "Dots"])); s.execute("select.set", &json!({"ids": [b]})).unwrap(); - assert_eq!(names(&mut s), json!(["Arrow"])); + let i = selected(&mut s); + assert_eq!( + (i["brushes"].clone(), i["sections"][0]["rows"].clone()), + (json!([{"name": "Dots", "type": "scatter"}]), json!([["Dots", "Scatter brush"]])) + ); + let text = brushes(&mut s, json!({"selectionOnly": true, "category": "brushes", "format": "text"}))["text"].as_str().unwrap().to_string(); + assert!(text.contains("\nBRUSHES\nDots: Scatter brush\n") && !text.contains("Charcoal"), "{text}"); // A stroke without a brush lists none. let plain = s.execute("shape.rectangle", &json!({"x": 60, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); s.execute("select.set", &json!({"ids": [plain]})).unwrap(); - assert_eq!(names(&mut s), json!([])); - let text = s.execute("document.info", &json!({"category": "brushes", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string(); - assert!(text.contains(&format!("\nBRUSHES\n{}\n", panel[0].as_str().unwrap())), "{text}"); - assert!(text.contains("\nCharcoal\n") && text.contains("\nArrow\n") && !text.contains("OBJECTS"), "{text}"); + assert_eq!(selected(&mut s)["brushNames"], json!([])); + } + + /// Document Info › Brushes leaves out brushes on strokes that draw nothing (hidden, with no + /// width or paint, a guide's, a compound path member's, a path's with no segment, type's that + /// draws no characters, a group's own), lists a brush the Brushes panel does + /// not hold with no type, and gives a name two panel brushes share the type the renderer uses. + #[test] + fn info_brushes_skip_undrawn_strokes_and_name_missing_brushes() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + let square = |s: &mut Session, x: f64| { + s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap() + }; + let (hidden, thin, guide, missing) = (square(&mut s, 0.0), square(&mut s, 20.0), square(&mut s, 40.0), square(&mut s, 60.0)); + let empty = square(&mut s, 80.0); + s.execute("brush.apply", &json!({"ids": [hidden, thin, guide, missing, empty], "name": "Charcoal"})).unwrap(); + let (p, q) = (square(&mut s, 100.0), square(&mut s, 120.0)); + s.execute("select.set", &json!({"ids": [p, q]})).unwrap(); + let compound = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + // A compound path's member: the compound path's own stroke draws it. + let NodeKind::Compound { children, .. } = &mut d.node_mut(NodeId(compound)).unwrap().kind else { panic!("not a compound path") }; + let member = &mut std::sync::Arc::make_mut(&mut children[0]).appearance; + member.set_stroke(Paint::solid(vectorcraft_color::Color::BLACK)); + member.stroke_mut().unwrap().brush = Some("Dots".into()); + d.node_mut(NodeId(hidden)).unwrap().appearance.stroke_mut().unwrap().visible = false; + d.node_mut(NodeId(thin)).unwrap().appearance.stroke_mut().unwrap().width = 0.0; + if let NodeKind::Path { guide: g, .. } = &mut d.node_mut(NodeId(guide)).unwrap().kind { + *g = true; + } + // A path with no segment: its stroke follows nothing. + if let NodeKind::Path { path, .. } = &mut d.node_mut(NodeId(empty)).unwrap().kind { + *path = vectorcraft_geom::PathData::default(); + } + d.node_mut(NodeId(missing)).unwrap().appearance.stroke_mut().unwrap().brush = Some("Gone".into()); + let i = s.execute("document.info", &json!({"category": "brushes"})).unwrap(); + assert_eq!(i["brushes"], json!([{"name": "Gone", "type": null}])); + assert_eq!(i["sections"][0]["rows"], json!([["Gone", "missing from the Brushes panel"]])); + // A stroke with no paint draws nothing. + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + d.node_mut(NodeId(missing)).unwrap().appearance.stroke_mut().unwrap().paint = Paint::None; + assert_eq!(s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushes"], json!([])); + // Two panel brushes with one name: the type is the one the renderer draws with, the last one's. + let twin = square(&mut s, 140.0); + s.execute("brush.apply", &json!({"ids": [twin], "name": "Charcoal"})).unwrap(); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let mut lib = vectorcraft_brush::library(d); + let kinds: Vec<vectorcraft_brush::BrushKind> = + ["Charcoal", "Dots"].iter().map(|n| lib.iter().find(|b| b.name == *n).unwrap().kind.clone()).collect(); + lib.extend(kinds.into_iter().map(|kind| vectorcraft_brush::Brush { name: "Twin".into(), kind })); + vectorcraft_brush::store(d, &lib); + d.node_mut(NodeId(twin)).unwrap().appearance.stroke_mut().unwrap().brush = Some("Twin".into()); + assert_eq!(s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushes"], json!([{"name": "Twin", "type": "scatter"}])); + // Type: a brushed stroke on drawn characters is listed, one on spaces alone is not. + let blank = s.execute("text.create", &json!({"x": 10, "y": 60, "text": " "})).unwrap()["id"].as_u64().unwrap(); + let word = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + for (id, name) in [(blank, "Dots"), (word, "Arrow")] { + let ap = &mut d.node_mut(NodeId(id)).unwrap().appearance; + ap.set_stroke(Paint::solid(vectorcraft_color::Color::BLACK)); + ap.stroke_mut().unwrap().brush = Some(name.into()); + } + let names = s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushNames"].clone(); + assert_eq!(names, json!(["Twin", "Arrow"])); + // A group's own stroke: the group draws its members. + let (m1, m2) = (square(&mut s, 160.0), square(&mut s, 180.0)); + s.execute("select.set", &json!({"ids": [m1, m2]})).unwrap(); + let group = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let ap = &mut d.node_mut(NodeId(group)).unwrap().appearance; + ap.set_stroke(Paint::solid(vectorcraft_color::Color::BLACK)); + ap.stroke_mut().unwrap().brush = Some("Leaves".into()); + let names = s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushNames"].clone(); + assert_eq!(names, json!(["Twin", "Arrow"])); } /// Document Info › Repeat Objects lists each Repeat object by name with its kind and the number diff --git a/docs/mcp.md b/docs/mcp.md index 66bcd163e5..62fa8085ad 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6374,8 +6374,12 @@ result carries the same files as a zip (`{name: "<name>.zip", dataBase64}`, entr `document.info {selectionOnly?, category?, format?: "text"}` adds `sections` (`[{id, title, rows: [[label, value]]}]`: document, objects, graphicStyles, brushes, spotColors, patterns, gradients, symbols, fonts, fontDetails, linkedImages, -embeddedImages, repeats), `brushNames` (the Brushes panel's brushes; with `selectionOnly`, the brushes the selected -objects' strokes use), `repeatObjects` (`[{id, name, type, instances, rows?, cols?}]`: every Repeat object, those in +embeddedImages, repeats), `brushNames` (the brushes the art's strokes use, each once, hidden strokes, strokes with no +paint or width, guides, paths with no segment, type that draws no characters, a group's or layer's own strokes, a compound path member's own strokes and an image's strokes without a +shape left out; with `selectionOnly`, the brushes the selected +objects' strokes use), `brushes` (`[{name, type}]`: those brushes with their types, `calligraphic`, `scatter`, `art`, +`pattern` or `bristle`, and `null` for a brush the Brushes panel does not hold; the brushes category's rows give each +name with its type, such as `Art brush`, or `missing from the Brushes panel`), `panelBrushNames` (the Brushes panel's brushes, whether art uses them or not), `repeatObjects` (`[{id, name, type, instances, rows?, cols?}]`: every Repeat object, those in groups and in other repeats included, with its type `radial`, `grid` or `mirror` and the number of instances it makes, and a grid's `rows` and `cols`; with `selectionOnly`, the Repeat objects in the selection), `patternObjects` (`[{id, name, patterns}]`: every object filled or stroked with a pattern, once each, those in groups and type objects diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c9f7db1285..bedd9e4e27 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1379,7 +1379,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Bristle Brush Options and `brush.new` take a bristle brush name of at most 31 characters, as the Brushes page states for the Bristle Brush Options. The Name field stops taking characters at 31, and `brush.new` and `brush.options` refuse a longer new name with an error that names the limit. The page does not say how the limit counts characters or what happens to longer names already saved; counting characters and the number added to a taken name, keeping longer saved names, and leaving `brush.duplicate` unlimited are VectorCraft's own rules. A bristle brush that a document or a saved file already holds under a longer name keeps it, and the other brush types take a name of any length. Test: `bristle_brush_names_have_at_most_31_characters` in `tests/brushsym.rs`. Public reference: [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) (archived copy). In the Brushes panel, Shift-click selects a range of brushes and Ctrl/Cmd-click adds or removes one. Delete Brush and Duplicate Brush then act on all of them (`brush.delete {names}`, `brush.duplicate {names}`). The Brushes panel's Delete Brush button and panel menu item ask "Delete the selected brushes?" with Yes and No when none of the brushes is in use. Alt-click (Option-click on macOS) on the button deletes them without asking, as the [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for panel deletion outside the Layers panel (archived copy). When a stroke uses one of the brushes, the button, Alt-click and the menu item all ask with Remove Strokes and Cancel; Remove Strokes deletes the brushes and the strokes that used them become plain strokes. Tests: `panels::brushes::tests`, `dialogs::brush_delete::tests`. - Document Info has a Brushes category after Graphic Styles. It lists the Brushes panel's brushes, or with Selection Only it lists the brushes that the selected objects' strokes use. The Document Info report and Package's report list them too (`document.info {category: "brushes"}`, `brushNames`). + Document Info has a Brushes category after Graphic Styles. It lists the names and types of the brushes applied to the artwork, as the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes, or with Selection Only the brushes that the selected objects' strokes use. The Document Info report and Package's report list them too (`document.info {category: "brushes"}`, `brushNames`, `brushes`). Listing each brush once, leaving out the Brushes panel's brushes no art uses (`panelBrushNames` lists the panel's brushes), hidden strokes, strokes with no paint or width, guides, paths with no segment, type that draws no characters, a group's or layer's own strokes, a compound path member's own strokes and an image's strokes without a shape, and listing a brush the panel does not hold as missing, are VectorCraft's own rules. Tests: `cmd::docinfo::tests::info_lists_the_brushes` and `info_brushes_skip_undrawn_strokes_and_name_missing_brushes` (vectorcraft-engine). Document Info has a Repeat Objects category after Embedded Images. It lists each radial, grid and mirror Repeat object by name with its kind and the number of instances it makes. For a grid it gives the rows and columns instead, followed by the number of instances it draws when that is fewer. With Selection Only it lists the Repeat objects in the selection. The Document Info report and Package's report list them too (`document.info {category: "repeats"}`, `repeatObjects`). Document Info's Pattern Objects category lists the objects whose fill or stroke is a pattern, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes, each by name with the patterns it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "patterns"}`, `patternObjects`). Listing an object with a pattern fill and a pattern stroke once, listing objects inside groups and type objects whose characters use a pattern, and leaving out hidden fills and strokes, strokes with no width, guides, a compound path member's own fills and strokes, an image's fills and strokes without a shape, characters the type does not draw and patterns no art uses, and counting the listed objects in Objects, are VectorCraft's own rules. Test: `cmd::docinfo::tests::info_lists_the_pattern_objects` (vectorcraft-engine). Document Info's Gradient Objects category lists the objects whose fill or stroke is a gradient, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes as the count of objects with gradient fills or strokes. Each object is listed by name with the gradients it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "gradients"}`, `gradientObjects`). Naming a linked gradient by its swatch and any other gradient by its kind, and leaving out paint that draws nothing by the rules Pattern Objects follows, gradient swatches no art uses, gradient meshes and gradients drawn by effects, are VectorCraft's own rules. Objects counts the listed objects as Gradient Objects. Test: `cmd::docinfo::tests::info_lists_the_gradient_objects` (vectorcraft-engine). From 780d00671aa6cfb66d8dbb7ea2e3f2175e1c361e Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 11:59:18 -0800 Subject: [PATCH 87/92] MCP: the UI catalog lists learn.locateTool crates/mcp/src/ui_catalog.rs is regenerated from crates/ui-egui/src/menus/ui_commands.rs, so `cargo xtask ui-commands --check` passes. --- crates/mcp/src/ui_catalog.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index 758fc67716..bfb82195d0 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -652,6 +652,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{tutorial: id (see learn.list)} show the Learn panel on its Skills tab with that tutorial's numbered steps, as choosing its skill and then the tutorial does; a missing, non-string or unknown id is an error and changes nothing; returns {skill, tutorial, title, steps: [text]}", ), + ( + "learn.locateTool", + "Show Tool Location", + "", + "{tool: a tool id, as tool.select takes} mark where that tool is for 3 seconds without choosing it, as the link on a tool's name in a Learn tutorial step does: its toolbar button, a row of its collapsed group's flyout (which opens), its group's torn-off strip (which comes to the front), or, when no shown toolbar holds it, its row in the All Tools drawer (which opens); a Tools panel hidden with Window › Tools shows again; Escape or the next locate removes the mark; a missing, non-string or unknown id is an error and changes nothing; returns {tool, location: toolbar|flyout|strip|allTools|unavailable, toolbar: the name of the toolbar made with New Toolbar that holds it, or null}, with reason when Presentation Mode or Tab hides the toolbars", + ), ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), ( "window.workspace", From b0cde0067e5ecb2af7243887d2b95086a6a113a8 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 12:45:28 -0800 Subject: [PATCH 88/92] Document Info: strokes and fills at opacity 0 list no brush or paint A stroke at an opacity of 0 draws nothing, so its brush is no longer listed in the Brushes category, brushNames or brushes. Pattern Objects, Gradient Objects and Spot Color Objects likewise leave out fills and strokes at an opacity of 0, and Objects no longer counts objects whose only such paint is at opacity 0. --- crates/engine/src/cmd/docinfo.rs | 28 ++++++++++++++++++---------- docs/mcp.md | 4 ++-- docs/target-app-parity.md | 4 ++-- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index d617fad088..ac79d0acc7 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> { "Document Info", ["Window", "Document Info"], None, - "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone", + "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone", has_doc, info ), @@ -348,7 +348,8 @@ type PaintNames<'a> = dyn Fn(&Paint) -> Vec<String> + 'a; /// The spot colors, patterns or gradients (`label`: [`spot_labels`], [`pattern_label`] or /// [`gradient_label`], each paint's names) that `n`'s own fills and strokes paint, each once. A -/// hidden fill or stroke and a stroke with no width paint nothing, and neither does any fill or +/// hidden fill or stroke, a fill or stroke at an opacity of 0 and a stroke with no width paint +/// nothing, and neither does any fill or /// stroke of a guide (drawn in the guide color), of a compound path's member (`members`: the /// compound path's own fills and strokes paint it) or of type whose layout draws nothing /// (`painted_runs`). An image's fills and strokes paint only where an effect gives them a shape. @@ -372,7 +373,7 @@ fn own_paints( .then(|| vectorcraft_render::effects::image_paint(Some(d), n).into_iter().flat_map(|(_, _, item)| label(item.paint())).collect()); let mut out: Vec<String> = vec![]; for item in &n.appearance.items { - if !item.visible() || matches!(item, vectorcraft_doc::AppearanceItem::Stroke(s) if s.width <= 0.0) { + if !item.visible() || item.opacity() <= 0.0 || matches!(item, vectorcraft_doc::AppearanceItem::Stroke(s) if s.width <= 0.0) { continue; } for name in label(item.paint()) { @@ -390,7 +391,7 @@ fn one_label(label: fn(&Paint) -> Option<String>) -> impl Fn(&Paint) -> Vec<Stri } /// The brushes `n`'s own strokes draw with, in the order of its appearance. A hidden stroke, a -/// stroke with no paint and a stroke with no width draw nothing, and neither does any stroke of a +/// stroke at an opacity of 0, a stroke with no paint and a stroke with no width draw nothing, and neither does any stroke of a /// guide (drawn in the guide color) or of a compound path's member (`members`: the compound path's /// own strokes draw it). An image's strokes draw only where an effect gives them a shape, and only /// paths, compound paths, type and images draw strokes of their own. @@ -399,7 +400,7 @@ fn applied_brushes(d: &Document, n: &Node, members: &HashSet<*const Node>) -> Ve return vec![]; } let brush = |item: &vectorcraft_doc::AppearanceItem| match item { - vectorcraft_doc::AppearanceItem::Stroke(st) if item.visible() && !st.paint.is_none() && st.width > 0.0 => { + vectorcraft_doc::AppearanceItem::Stroke(st) if item.visible() && st.opacity > 0.0 && !st.paint.is_none() && st.width > 0.0 => { st.brush.clone().filter(|b| !b.is_empty()) } _ => None, @@ -827,8 +828,8 @@ mod tests { assert_eq!(selected(&mut s)["brushNames"], json!([])); } - /// Document Info › Brushes leaves out brushes on strokes that draw nothing (hidden, with no - /// width or paint, a guide's, a compound path member's, a path's with no segment, type's that + /// Document Info › Brushes leaves out brushes on strokes that draw nothing (hidden, at an + /// opacity of 0, with no width or paint, a guide's, a compound path member's, a path's with no segment, type's that /// draws no characters, a group's own), lists a brush the Brushes panel does /// not hold with no type, and gives a name two panel brushes share the type the renderer uses. #[test] @@ -839,8 +840,8 @@ mod tests { s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap() }; let (hidden, thin, guide, missing) = (square(&mut s, 0.0), square(&mut s, 20.0), square(&mut s, 40.0), square(&mut s, 60.0)); - let empty = square(&mut s, 80.0); - s.execute("brush.apply", &json!({"ids": [hidden, thin, guide, missing, empty], "name": "Charcoal"})).unwrap(); + let (empty, faint) = (square(&mut s, 80.0), square(&mut s, 200.0)); + s.execute("brush.apply", &json!({"ids": [hidden, thin, guide, missing, empty, faint], "name": "Charcoal"})).unwrap(); let (p, q) = (square(&mut s, 100.0), square(&mut s, 120.0)); s.execute("select.set", &json!({"ids": [p, q]})).unwrap(); let compound = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); @@ -852,6 +853,8 @@ mod tests { member.stroke_mut().unwrap().brush = Some("Dots".into()); d.node_mut(NodeId(hidden)).unwrap().appearance.stroke_mut().unwrap().visible = false; d.node_mut(NodeId(thin)).unwrap().appearance.stroke_mut().unwrap().width = 0.0; + // A stroke at an opacity of 0 draws nothing. + d.node_mut(NodeId(faint)).unwrap().appearance.stroke_mut().unwrap().opacity = 0.0; if let NodeKind::Path { guide: g, .. } = &mut d.node_mut(NodeId(guide)).unwrap().kind { *g = true; } @@ -1009,6 +1012,11 @@ mod tests { s.execute("paint.setFill", &json!({"ids": [e], "none": true})).unwrap(); s.execute("paint.setStroke", &json!({"ids": [e], "swatch": "Dots"})).unwrap(); s.execute("appearance.setItem", &json!({"ids": [e], "index": 1, "weight": 0})).unwrap(); + // Nor does a pattern fill at an opacity of 0. + let clear = square(&mut s, 220.0); + s.execute("paint.setStroke", &json!({"ids": [clear], "none": true})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [clear], "swatch": "Dots"})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [clear], "index": 0, "opacity": 0})).unwrap(); // A type object with no characters paints nothing either. let empty = s.execute("text.create", &json!({"x": 0, "y": 300, "text": ""})).unwrap()["id"].clone(); s.execute("paint.setFill", &json!({"ids": [empty], "swatch": "Dots"})).unwrap(); @@ -1069,7 +1077,7 @@ mod tests { assert_eq!(i["patternNames"], json!(["Dots"])); // Objects counts the objects that Pattern Objects lists. assert_eq!(i["objects"]["patterns"].as_u64(), Some(5), "{}", i["objects"]); - s.execute("select.set", &json!({"ids": [d, e, empty, blank, guide, compound, image.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [d, e, clear, empty, blank, guide, compound, image.0]})).unwrap(); let i = s.execute("document.info", &json!({"selectionOnly": true})).unwrap(); assert!(i["objects"]["patterns"].is_null() && i["patternObjects"] == json!([]), "{}", i["objects"]); let i = s.execute("document.info", &json!({"category": "patterns"})).unwrap(); diff --git a/docs/mcp.md b/docs/mcp.md index 62fa8085ad..95cac672ae 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6374,7 +6374,7 @@ result carries the same files as a zip (`{name: "<name>.zip", dataBase64}`, entr `document.info {selectionOnly?, category?, format?: "text"}` adds `sections` (`[{id, title, rows: [[label, value]]}]`: document, objects, graphicStyles, brushes, spotColors, patterns, gradients, symbols, fonts, fontDetails, linkedImages, -embeddedImages, repeats), `brushNames` (the brushes the art's strokes use, each once, hidden strokes, strokes with no +embeddedImages, repeats), `brushNames` (the brushes the art's strokes use, each once, hidden strokes, strokes at an opacity of 0, strokes with no paint or width, guides, paths with no segment, type that draws no characters, a group's or layer's own strokes, a compound path member's own strokes and an image's strokes without a shape left out; with `selectionOnly`, the brushes the selected objects' strokes use), `brushes` (`[{name, type}]`: those brushes with their types, `calligraphic`, `scatter`, `art`, @@ -6383,7 +6383,7 @@ name with its type, such as `Art brush`, or `missing from the Brushes panel`), ` groups and in other repeats included, with its type `radial`, `grid` or `mirror` and the number of instances it makes, and a grid's `rows` and `cols`; with `selectionOnly`, the Repeat objects in the selection), `patternObjects` (`[{id, name, patterns}]`: every object filled or stroked with a pattern, once each, those in groups and type objects -whose drawn characters use a pattern included, hidden fills and strokes, strokes with no width, guides, a compound path +whose drawn characters use a pattern included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, a compound path member's own fills and strokes, an image's fills and strokes that no Convert to Shape or Outline Object effect gives a shape, and characters that draw nothing (spaces and line breaks without an underline or strikethrough) left out, with the patterns it uses, which the patterns category lists as Pattern Objects and `objects.patterns` counts; with `selectionOnly`, those in the selection), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bedd9e4e27..534b0f97b5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1379,9 +1379,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Bristle Brush Options and `brush.new` take a bristle brush name of at most 31 characters, as the Brushes page states for the Bristle Brush Options. The Name field stops taking characters at 31, and `brush.new` and `brush.options` refuse a longer new name with an error that names the limit. The page does not say how the limit counts characters or what happens to longer names already saved; counting characters and the number added to a taken name, keeping longer saved names, and leaving `brush.duplicate` unlimited are VectorCraft's own rules. A bristle brush that a document or a saved file already holds under a longer name keeps it, and the other brush types take a name of any length. Test: `bristle_brush_names_have_at_most_31_characters` in `tests/brushsym.rs`. Public reference: [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) (archived copy). In the Brushes panel, Shift-click selects a range of brushes and Ctrl/Cmd-click adds or removes one. Delete Brush and Duplicate Brush then act on all of them (`brush.delete {names}`, `brush.duplicate {names}`). The Brushes panel's Delete Brush button and panel menu item ask "Delete the selected brushes?" with Yes and No when none of the brushes is in use. Alt-click (Option-click on macOS) on the button deletes them without asking, as the [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for panel deletion outside the Layers panel (archived copy). When a stroke uses one of the brushes, the button, Alt-click and the menu item all ask with Remove Strokes and Cancel; Remove Strokes deletes the brushes and the strokes that used them become plain strokes. Tests: `panels::brushes::tests`, `dialogs::brush_delete::tests`. - Document Info has a Brushes category after Graphic Styles. It lists the names and types of the brushes applied to the artwork, as the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes, or with Selection Only the brushes that the selected objects' strokes use. The Document Info report and Package's report list them too (`document.info {category: "brushes"}`, `brushNames`, `brushes`). Listing each brush once, leaving out the Brushes panel's brushes no art uses (`panelBrushNames` lists the panel's brushes), hidden strokes, strokes with no paint or width, guides, paths with no segment, type that draws no characters, a group's or layer's own strokes, a compound path member's own strokes and an image's strokes without a shape, and listing a brush the panel does not hold as missing, are VectorCraft's own rules. Tests: `cmd::docinfo::tests::info_lists_the_brushes` and `info_brushes_skip_undrawn_strokes_and_name_missing_brushes` (vectorcraft-engine). + Document Info has a Brushes category after Graphic Styles. It lists the names and types of the brushes applied to the artwork, as the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes, or with Selection Only the brushes that the selected objects' strokes use. The Document Info report and Package's report list them too (`document.info {category: "brushes"}`, `brushNames`, `brushes`). Listing each brush once, leaving out the Brushes panel's brushes no art uses (`panelBrushNames` lists the panel's brushes), hidden strokes, strokes at an opacity of 0, strokes with no paint or width, guides, paths with no segment, type that draws no characters, a group's or layer's own strokes, a compound path member's own strokes and an image's strokes without a shape, and listing a brush the panel does not hold as missing, are VectorCraft's own rules. Tests: `cmd::docinfo::tests::info_lists_the_brushes` and `info_brushes_skip_undrawn_strokes_and_name_missing_brushes` (vectorcraft-engine). Document Info has a Repeat Objects category after Embedded Images. It lists each radial, grid and mirror Repeat object by name with its kind and the number of instances it makes. For a grid it gives the rows and columns instead, followed by the number of instances it draws when that is fewer. With Selection Only it lists the Repeat objects in the selection. The Document Info report and Package's report list them too (`document.info {category: "repeats"}`, `repeatObjects`). - Document Info's Pattern Objects category lists the objects whose fill or stroke is a pattern, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes, each by name with the patterns it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "patterns"}`, `patternObjects`). Listing an object with a pattern fill and a pattern stroke once, listing objects inside groups and type objects whose characters use a pattern, and leaving out hidden fills and strokes, strokes with no width, guides, a compound path member's own fills and strokes, an image's fills and strokes without a shape, characters the type does not draw and patterns no art uses, and counting the listed objects in Objects, are VectorCraft's own rules. Test: `cmd::docinfo::tests::info_lists_the_pattern_objects` (vectorcraft-engine). + Document Info's Pattern Objects category lists the objects whose fill or stroke is a pattern, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes, each by name with the patterns it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "patterns"}`, `patternObjects`). Listing an object with a pattern fill and a pattern stroke once, listing objects inside groups and type objects whose characters use a pattern, and leaving out hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, a compound path member's own fills and strokes, an image's fills and strokes without a shape, characters the type does not draw and patterns no art uses, and counting the listed objects in Objects, are VectorCraft's own rules. Test: `cmd::docinfo::tests::info_lists_the_pattern_objects` (vectorcraft-engine). Document Info's Gradient Objects category lists the objects whose fill or stroke is a gradient, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes as the count of objects with gradient fills or strokes. Each object is listed by name with the gradients it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "gradients"}`, `gradientObjects`). Naming a linked gradient by its swatch and any other gradient by its kind, and leaving out paint that draws nothing by the rules Pattern Objects follows, gradient swatches no art uses, gradient meshes and gradients drawn by effects, are VectorCraft's own rules. Objects counts the listed objects as Gradient Objects. Test: `cmd::docinfo::tests::info_lists_the_gradient_objects` (vectorcraft-engine). Document Info's Spot Color Objects category lists the objects that use spot colors, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes as objects using spot color separations. Each object is listed by name with the spot colors it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "spotColors"}`, `spotObjects`). Taking a solid color or a gradient stop linked to a spot swatch at a tint above 0 and a grayscale image colorized with one, leaving out process colors that look the same, Registration, gradients whose stops are all at an opacity of 0, paint that draws nothing by the rules Pattern Objects follows, spot swatches no art uses and spot colors inside patterns, symbols, opacity masks, effects and placed files, are VectorCraft's own rules. Objects counts the listed objects as Spot Color Objects. Tests: `cmd::docinfo::tests::info_lists_the_spot_color_objects` and `spot_color_objects_take_colorized_images_and_skip_clear_gradients` (vectorcraft-engine). Document Info's Save… writes the category the panel shows, with Selection Only applied, as the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes ("Exports the selected category's visible information to a text file"). With All Categories shown it writes every category, which is VectorCraft's own rule (`docInfo.save {category}`). Test: `panels::doc_info::tests::save_writes_the_category_the_panel_shows`. From 2e95fd857333a615e6f8ef23a325d2a9582822be Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 12:46:14 -0800 Subject: [PATCH 89/92] Transform: a stroke runs on through a joint too shallow to bevel A click just past the end of two segments that meet with almost no turn, as the segments of a warped edge do, missed a beveled or mitered stroke: the joint has no bevel, and the check counted that as a miss. It now counts as painted, so the eyedropper samples an envelope's stroke there again. --- crates/doc/src/reach.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs index 4648f3087e..82622ace1d 100644 --- a/crates/doc/src/reach.rs +++ b/crates/doc/src/reach.rs @@ -446,7 +446,8 @@ impl StrokeLayer { /// paint `p`, which is within `side` (and `tol`) of segment `i` of the subpath `segs` /// (`closed`: a closed one) and nearest to it at `t`? It does when `p` is not past the end of /// the segment that `t` is at, by more than `tol`, and past it when a round cap or a round join - /// paints there, or when `p` is within `tol` of the bevel's triangle. Projecting caps and + /// paints there, when the path does not turn at that end, or when `p` is within `tol` of the + /// bevel's triangle. Projecting caps and /// miter spikes are checked apart. A curve that bends more tightly than `side` ([`gentle`]) /// counts as painting round its ends. fn paints_at_end(&self, segs: &[PathSeg], closed: bool, (i, t): (usize, f64), p: Point, side: f64, tol: f64) -> bool { @@ -466,7 +467,8 @@ impl StrokeLayer { return true; } let (t_in, t_out) = if at_start { (tangent(other, 1.0), tangent(seg, 0.0)) } else { (tangent(seg, 1.0), tangent(other, 0.0)) }; - bevel(at, t_in, t_out, side).is_some_and(|([a, b], _)| in_convex(&[at, a, b], p, tol)) + // A joint where the path does not turn has no bevel: the stroke runs on through it. + bevel(at, t_in, t_out, side).is_none_or(|([a, b], _)| in_convex(&[at, a, b], p, tol)) } /// Is `p` within `tol` of where the start (`end == false`) or end arrowhead at `at` (the path @@ -709,6 +711,17 @@ mod tests { assert!(stroke(4.0, |s| s.cap = LineCap::Round).hits(&line, FillRule::NonZero, Point::new(1.0, -1.5), 0.25)); } + #[test] + fn a_joint_too_shallow_to_bevel_paints_past_both_segments_ends() { + // The path turns by about 0.57° at (100, -0.5), too little to bevel, so the stroke runs on + // through the joint: 3.5 pt above it is past the end of both segments and still painted. + let kink = polyline(&[(0.0, 0.0), (100.0, -0.5), (200.0, 0.0)]); + let above = Point::new(100.0, -4.0); + for join in [LineJoin::Bevel, LineJoin::Miter, LineJoin::Round] { + assert!(stroke(10.0, |s| s.join = join).hits(&kink, FillRule::NonZero, above, 0.0), "{join:?}"); + } + } + /// An open path through `points`. fn polyline(points: &[(f64, f64)]) -> BezPath { let mut bp = BezPath::new(); From c513114674ad41cc2d29a95c7555fe5b3b142e1b Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 12:35:36 -0800 Subject: [PATCH 90/92] Gradients: Expand reports a capped gradient as unavailable - object.expand.info reports no gradient or fill to expand for a gradient that repeats in more runs across its box than are written, matching object.expand, which leaves it a gradient. For a path that a painting live effect draws, it measures each piece the effect returns. - CSS writes a repeating gradient's stop positions, and a repeating radial gradient's radius, with enough decimals that their rounding adds up to at most a hundredth of a period across the box (at least four for positions and three for the radius). - docs/mcp.md says that sharp steps and swatch-linked stops are the last to be left out rather than always kept, and that the CSS conic limit counts stops before midpoints and Perceptual curves add more. --- crates/engine/src/cmd/expand.rs | 47 +++++++++++++++++++-- crates/engine/src/tests/objexpand.rs | 24 +++++++++-- crates/engine/src/tests/plugins.rs | 21 ++++++++++ crates/svg/src/css.rs | 63 +++++++++++++++++++++++----- crates/svg/tests/it/css_rules.rs | 15 +++++++ docs/mcp.md | 7 +++- 6 files changed, 157 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index bcf883b2f3..3eab236e36 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -194,10 +194,12 @@ impl Expandable { NodeKind::Text(t) => t.runs.iter().any(|r| r.style.has_stroke()), _ => shape && !c.appearance.stroke_paint().is_none(), }; - e.gradient |= match &c.kind { - NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))), - _ => shape && c.appearance.items.iter().any(|i| gradient_fill(i).is_some()), - }; + // Once one is found, the next path is not measured. + e.gradient = e.gradient + || match &c.kind { + NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))), + _ => shape && gradient_expands(d, c), + }; e.fill |= e.gradient; e.fill |= match &c.kind { NodeKind::Text(t) => t.runs.iter().any(|r| matches!(&r.style.fill, Paint::Pattern { pattern, .. } if d.pattern(pattern).is_some())), @@ -222,6 +224,43 @@ fn gradient_fill(i: &AppearanceItem) -> Option<(&FillLayer, &GradientPaint)> { } } +/// Whether path `l` has a gradient fill that Fill expands ([`expand_fills`]): one that repeats or +/// reflects stays a gradient when its box needs more runs than are written ([`padded_on`]). For a +/// path that live-effect plug-ins paint, whether a piece of the art they paint has one +/// ([`expand_pieces`]). +fn gradient_expands(d: &Document, l: &Node) -> bool { + if !l.appearance.items.iter().any(|i| gradient_fill(i).is_some()) { + return false; + } + if !vectorcraft_render::effects::has_painted_effects(l) { + return piece_gradient_expands(d, l); + } + let Some(art) = vectorcraft_render::effects::reshape(Some(d), l, None) else { return false }; + let mut any = false; + art.walk(&mut |c| { + any = any + || (matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) + && !c.is_ruler_guide() + && c.appearance.items.iter().any(|i| gradient_fill(i).is_some()) + && piece_gradient_expands(d, c)); + }); + any +} + +/// [`gradient_expands`] for a path that no live-effect plug-in paints ([`expand_piece_fills`]). +fn piece_gradient_expands(d: &Document, l: &Node) -> bool { + if !l.appearance.items.iter().filter_map(gradient_fill).any(|(_, g)| g.gradient.is_spread()) { + return l.appearance.items.iter().any(|i| gradient_fill(i).is_some()); + } + let reshaped = reshaped(d, l); + let l = reshaped.as_ref().unwrap_or(l); + let Some((path, _)) = node_path(l) else { return false }; + let Some(bounds) = path.bounds() else { return false }; + l.appearance.items.iter().filter(|i| gradient_fill(i).is_some()).any(|item| { + fill_shape(d, l, item, &path, bounds).is_some_and(|(_, _, b, moved)| gradient_fill(&moved).is_some_and(|(_, g)| padded_on(g, b).is_some())) + }) +} + /// The definition and placement of a visible pattern fill whose pattern `d` defines. fn pattern_fill<'a, 'd>(d: &'d Document, i: &'a AppearanceItem) -> Option<(&'a FillLayer, &'d PatternDef, Affine)> { match i { diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 65d638e641..06c5edcac5 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -510,14 +510,30 @@ fn a_spread_gradient_expands_into_whole_bands_for_each_run() { /// A gradient that repeats across a box in more runs than are written (700 runs of 0.2 pt) is not /// expanded into its first runs and a flat last color: the fill stays a gradient, and the rest of -/// the object (its stroke) still expands. +/// the object (its stroke) still expands. `object.expand.info` reports no gradient or fill to +/// expand, and Expand with only Fill on reports nothing to expand; the same gradient in 70 runs +/// (10 repeats) reports both. #[test] fn a_spread_gradient_with_more_runs_than_written_stays_a_gradient() { + let gradient = |repeats: u32| { + json!({"kind": "linear", "spread": "repeat", "repeats": repeats, "start": [30, 100], "end": [50, 100], + "stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 1, "color": "#004080"}]}) + }; + for (repeats, expands) in [(10, true), (100, false)] { + let mut s = session(); + gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gradient(repeats), true); + let info = s.execute("object.expand.info", &json!({})).unwrap(); + assert_eq!( + (info["gradient"].clone(), info["fill"].clone(), info["stroke"].clone()), + (json!(expands), json!(expands), json!(true)), + "{repeats}" + ); + let only_fill = s.execute("object.expand", &json!({"object": false, "stroke": false})); + assert_eq!(only_fill.is_ok(), expands, "{repeats}: {only_fill:?}"); + } for to in [json!({"steps": 240}), json!({"gradient": "mesh"})] { let mut s = session(); - let gr = json!({"kind": "linear", "spread": "repeat", "repeats": 100, "start": [30, 100], "end": [50, 100], - "stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 1, "color": "#004080"}]}); - gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gr, true); + gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gradient(100), true); let before = render(&s); run(&mut s, "object.expand", to.clone()); let after = render(&s); diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 8d1570e996..77c8bf56a2 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -668,6 +668,27 @@ fn expanding_a_gradient_fill_keeps_each_piece_s_own_paint() { } } +/// `object.expand.info` measures each piece a painted effect returns: a piece whose repeating +/// gradient needs more runs across it than are written (1,000) reports no gradient to expand, as +/// Expand leaves it a gradient, and the same gradient in 10 runs reports one. +#[test] +fn expand_info_measures_the_gradient_on_each_painted_piece() { + for (end, expands) in [(120.0, true), (100.2, false)] { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let gradient = json!({"kind": "linear", "spread": "repeat", "repeats": 10, "start": [100, 50], "end": [end, 50]}); + run(&mut s, "paint.setFill", json!({"ids": [id.0], "gradient": gradient})); + let out = json!({"objects": [{"path": square_at(100.0)}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.expandruns", true), &out)); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.expandruns"})); + run(&mut s, "select.set", json!({"ids": [id.0]})); + let info = s.execute("object.expand.info", &json!({})).unwrap(); + assert_eq!((info["gradient"].clone(), info["fill"].clone()), (json!(expands), json!(expands)), "{end}"); + let only_fill = s.execute("object.expand", &json!({"object": false, "fill": true, "stroke": false})); + assert_eq!(only_fill.is_ok(), expands, "{end}: {only_fill:?}"); + } +} + /// Object › Expand of a pattern fill works piece by piece: a piece that keeps the pattern has its /// tiles expanded, and a piece with a paint of its own keeps that paint. #[test] diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index da2049fe8d..f7e067aa30 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -206,6 +206,14 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props { p } +/// The decimals, at least `least` and at most 12, for numbers that place a period of length +/// `period` repeated out to `reach` (in the same units): each period then rounds by at most +/// 10^-decimals, and over the `reach / period` periods that adds up to at most a hundredth of one. +fn period_decimals(period: f64, reach: f64, least: u8) -> u8 { + let d = (100.0 * reach / (period * period)).log10().ceil(); + if d.is_nan() || d == f64::INFINITY { 12 } else { d.clamp(f64::from(least), 12.0) as u8 } +} + /// A colour at `opacity`: `#rrggbb` when opaque, else `rgba()`. fn color_css(c: &Color, opacity: f32) -> String { if opacity >= 1.0 { @@ -247,13 +255,22 @@ impl CssUnits { /// `points` in these units (`0` without a unit). fn len(self, points: f64) -> String { - let per_point = match self { + self.len_to(points, 3) + } + + /// One point in these units. + fn per_point(self) -> f64 { + match self { CssUnits::Px | CssUnits::Pt => 1.0, CssUnits::Mm => 25.4 / 72.0, CssUnits::Cm => 2.54 / 72.0, CssUnits::In => 1.0 / 72.0, - }; - match fmt_num(points * per_point, 3) { + } + } + + /// [`Self::len`] with `decimals` decimals. + fn len_to(self, points: f64, decimals: u8) -> String { + match fmt_num(points * self.per_point(), decimals) { zero if zero == "0" => zero, v => format!("{v}{}", self.name()), } @@ -583,9 +600,18 @@ impl Rules<'_> { _ => (unrolled, geom, ""), }; // A repeating gradient's period is the span from its first stop to its last, repeated - // across the box: its positions get four decimals, so the period stays in place there. - let decimals = if repeating.is_empty() { 2 } else { 4 }; - let stops = |pos: &dyn Fn(f32) -> f64| { + // across the box (from 0% to `reach`%): its positions get at least four decimals, and more + // when the box holds so many periods that their rounding would add up to more than a + // hundredth of one ([`period_decimals`]). + let span = gradient.expanded().next().zip(gradient.expanded().last()).map(|(a, z)| (a.0, z.0)); + let decimals = |pos: &dyn Fn(f32) -> f64, reach: f64| match span { + Some((a, z)) if !repeating.is_empty() => { + let (p0, p1) = (pos(a) * 100.0, pos(z) * 100.0); + period_decimals((p1 - p0).abs(), p0.abs().max((reach - p0).abs()), 4) + } + _ => 2, + }; + let stops = |pos: &dyn Fn(f32) -> f64, decimals: u8| { let stops: Vec<String> = gradient.expanded().map(|(t, c, o, _)| format!("{} {}%", color_css(&c, o * opacity), fmt_num(pos(t) * 100.0, decimals))).collect(); stops.join(", ") @@ -604,7 +630,7 @@ impl Rules<'_> { let line = (b.width() * dir.x).abs() + (b.height() * dir.y).abs(); let from = (geom.start - b.center()).dot(dir); let pos = |t: f32| if line > 1e-9 { (from + t as f64 * length) / line + 0.5 } else { t as f64 }; - Some(format!("{repeating}linear-gradient({}deg, {})", fmt_num(angle, 2), stops(&pos))) + Some(format!("{repeating}linear-gradient({}deg, {})", fmt_num(angle, 2), stops(&pos, decimals(&pos, 100.0)))) } // CSS has no diamond gradient: the ellipse through the square's corners stands in. GradientKind::Radial | GradientKind::Diamond => { @@ -614,13 +640,30 @@ impl Rules<'_> { } else if geom.focal.is_some() { self.warn("radial gradients start at their centre (an off-centre focal point is left out)"); } + // The positions are a share of the radius, and the box reaches out to its farthest + // corner. A repeating gradient's period scales with its radius, so the radius gets + // the decimals that keep the periods in place out to that corner too. + let units = self.opts.units; + let small = r * geom.aspect.abs().min(1.0); + let far = [(b.x0, b.y0), (b.x1, b.y0), (b.x0, b.y1), (b.x1, b.y1)] + .into_iter() + .map(|(x, y)| (Point::new(x, y) - geom.start).hypot()) + .fold(0.0, f64::max); + let reach = if small > 1e-12 { 100.0 * far / small } else { 0.0 }; + let pos = |t: f32| t as f64; + let len = match span { + Some((a, z)) if !repeating.is_empty() => { + period_decimals(small * f64::from(z - a).abs() * units.per_point(), far * units.per_point(), 3) + } + _ => 3, + }; let shape = if (geom.aspect - 1.0).abs() < 1e-9 { - format!("circle {}", self.len(r)) + format!("circle {}", units.len_to(r, len)) } else { - format!("ellipse {} {}", self.len(r), self.len(r * geom.aspect)) + format!("ellipse {} {}", units.len_to(r, len), units.len_to(r * geom.aspect, len)) }; let at = format!("at {} {}", self.len(geom.start.x - b.x0), self.len(geom.start.y - b.y0)); - Some(format!("{repeating}radial-gradient({shape} {at}, {})", stops(&|t| t as f64))) + Some(format!("{repeating}radial-gradient({shape} {at}, {})", stops(&pos, decimals(&pos, reach)))) } GradientKind::Conic => { // CSS sweeps clockwise from the `from` angle, 0deg straight up diff --git a/crates/svg/tests/it/css_rules.rs b/crates/svg/tests/it/css_rules.rs index 8abd6eb48b..8bf50d4add 100644 --- a/crates/svg/tests/it/css_rules.rs +++ b/crates/svg/tests/it/css_rules.rs @@ -366,6 +366,21 @@ fn spread_gradients_give_repeating_css_gradients() { css(GradientKind::Linear, GradientSpread::Repeat, short).as_deref(), Some("repeating-linear-gradient(90deg, #ffffff 0%, #000000 2.5863%)") ); + // About 1,000 periods across the box: six decimals, so their rounding adds up to at most a + // hundredth of a period at the far side (with four, `0.1%` would be almost half a period out + // there). + let tiny = GradientGeom { end: Point::new(0.400196, 30.0), ..across }; + assert_eq!( + css(GradientKind::Linear, GradientSpread::Repeat, tiny).as_deref(), + Some("repeating-linear-gradient(90deg, #ffffff 0%, #000000 0.100049%)") + ); + // A radial period of 0.03085 px out to a corner 67 px away: its radius keeps the decimals that + // hold the periods in place there. + let dot = GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(40.1234, 30.0), aspect: 1.0, focal: None }; + assert_eq!( + css(GradientKind::Radial, GradientSpread::Repeat, dot).as_deref(), + Some("repeating-radial-gradient(circle 0.03085px at 40px 30px, #ffffff 0%, #000000 100%)") + ); let ring = GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(80.0, 30.0), aspect: 1.0, focal: None }; assert_eq!( css(GradientKind::Radial, GradientSpread::Reflect, ring).as_deref(), diff --git a/docs/mcp.md b/docs/mcp.md index f9df09112d..a3910bec2f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1205,8 +1205,11 @@ legacy `.ai`), and for a radial or diamond gradient no farther from its center t times the smallest gap between its stops (measured in runs) when that is less. Past those it pads, with a warning. The `.ai` editing data has no spread either: it holds the stops repeated across the shape, at most 256 of them, with a warning. When the runs need more stops than these limits allow, every run is still written, without the stops that -change its colors least (its ends, sharp steps and swatch-linked stops stay); the same applies to a gradient along -or across a stroke, and to CSS's conic gradient, past 16,384 stops, and to Outline Stroke's mesh past 1,024. PDF +change its colors least. Each run keeps its ends; sharp steps and swatch-linked stops are the last to be left out, +but a run with more of them than its share of the limit loses some. The same applies to a gradient along or across a +stroke, and to CSS's conic gradient, past 16,384 stops, and to Outline Stroke's mesh past 1,024. For CSS the 16,384 +counts the gradient's stops before they are written: CSS then adds a stop for each midpoint, and for a Perceptual +gradient stops along each span's curve, so the written gradient can hold more. PDF import finds a sharp step between two colors in an axial or radial shading by halving the spans between its samples, so the step reads back where it is in the file (to 1/64 × 2^-14 of the axis) instead of blurred across the samples around it. A shading takes at most 16,384 samples, so the steps past the point where that budget runs out stay From a670c3072bebef442cd7654d2b4595bf27d83642 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 13:18:17 -0800 Subject: [PATCH 91/92] MCP: document.json names linked JavaScript files outside the read roots by file name With automation read roots in force, document.json returned each entry of svg_scripts unchanged, so a JavaScript file linked from outside the read roots kept its full path in the reply. The reply copy now names such a file by its file name alone. An entry given as a URL is kept, and the stored document is not changed. --- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/links.rs | 7 ++++++- crates/engine/src/tests/placed_document.rs | 11 ++++++++++- docs/mcp.md | 3 ++- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 157049aa7c..41949ddeb9 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> { has_doc, doc_compare ), - cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model (with automation read roots, a linked or placed file outside them is named by its file name alone, without its relative path, size or date, in brush art too, and so is a data set's image value outside them; a relative path that leads outside them is left out; a stored brush entry that does not read as a brush is left out; a folder outside them is left out of export_settings)", has_doc, |s, _| { + cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model (with automation read roots, a linked or placed file outside them is named by its file name alone, without its relative path, size or date, in brush art too, and so are a data set's image value and a linked JavaScript file in svg_scripts outside them (a URL is kept); a relative path that leads outside them is left out; a stored brush entry that does not read as a brush is left out; a folder outside them is left out of export_settings)", has_doc, |s, _| { let st = s.doc()?; let d = super::links::with_hidden_paths_named(&st.doc, super::links::LinkBase::of(st)); Ok(serde_json::to_value(&*d).unwrap_or(Value::Null)) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 162bb679f6..ab6dd3e56a 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -1200,7 +1200,8 @@ pub(crate) fn with_found_paths(d: &mut Document, base: LinkBase) { /// `d`, a document saved at `doc_path`, as a reply to the caller asking now may show it: without /// read roots in force, `d` itself; with them, a copy in which each link [`path_shown`] does not /// allow names its file alone, without its relative path or the size and date kept of its file, -/// and each data set's image value it does not allow names its file alone too. The folder Export +/// and each data set's image value and linked JavaScript file (`svg_scripts`) it does not allow +/// names its file alone too, while a JavaScript file given as a URL stays as it is. The folder Export /// for Screens last wrote to is left out of the export settings when it is outside the read roots. /// A link whose path is allowed but whose relative path leads outside the read roots loses its /// relative path. Links in the layers, symbols, graph designs, patterns, the pattern being edited @@ -1266,6 +1267,10 @@ pub(crate) fn with_hidden_paths_named<'a>(d: &'a Document, base: LinkBase) -> Co } } } + let script_hidden = |f: &str| !path_shown(f, doc_path) && !matches!(fileio::document_path(f, doc_path), Err(craft_docpath::Refused::Url)); + for f in d.svg_scripts.iter_mut().filter(|f| script_hidden(f)) { + *f = LinkInfo::new(f.as_str()).name().to_string(); + } Cow::Owned(d) } diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index 1a6ee921f2..2420ef265c 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -1102,6 +1102,13 @@ fn document_replies_leave_out_paths_outside_the_read_roots() { s.execute("variable.define", &json!({"name": "Photo", "kind": "image"})).unwrap(); s.execute("dataset.new", &json!({"name": "Row", "values": {"Photo": pic}})).unwrap(); let image = r["ids"][0].as_u64().unwrap(); + // A linked JavaScript file outside the read roots, and one given as a URL. + let script = away.file("menu.js"); + write(&script, b"void 0;"); + let url = "https://example.com/scripts/menu.js"; + for file in [script.as_str(), url] { + s.execute("svgInteractivity.addFile", &json!({"file": file})).unwrap(); + } let poster = away.file("poster.vectorcraft"); save(&mut s, &poster); // The folder Export for Screens wrote to, and a pattern of the linked image being edited: its @@ -1124,11 +1131,13 @@ fn document_replies_leave_out_paths_outside_the_read_roots() { assert!(model.contains(&format!("\"path\":\"{name}\"")), "{name}: {model}"); } assert!(model.contains("{\"image\":\"photo.png\"}"), "{model}"); + assert!(model.contains(&format!("\"svg_scripts\":[\"menu.js\",\"{url}\"]")), "{model}"); // The document keeps its links, and a caller whose read roots hold the files sees every path. assert_eq!(placed(&s, id).link.path, src); + assert_eq!(s.doc().unwrap().doc.svg_scripts, [script.as_str(), url]); for model in [ask(&mut s, &away.0, "document.json"), s.execute("document.json", &json!({})).unwrap()] { let model = model.to_string(); - for path in [&src, &pic, &out] { + for path in [&src, &pic, &out, &script] { assert!(model.contains(&json!(path).to_string()), "{path}: {model}"); } } diff --git a/docs/mcp.md b/docs/mcp.md index 9191ab8bd7..43840880bd 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -227,7 +227,8 @@ the suite) keep the agent to the folders you give it: leaves out the link's `relative`, `size` and `modified`. This covers the art of the brushes in `unknown.brushes` too. A link whose file is inside them keeps its `path` but leaves out a `relative` that leads outside them. A stored brush entry that does not read as a brush is left out of `unknown.brushes`. `document.json` also names a data set's image - value outside them by its file name alone, and leaves out the `folder` of `export_settings` (the settings Export + value and a linked JavaScript file in `svg_scripts` outside them by its file name alone (a JavaScript file given + as a URL is kept), and leaves out the `folder` of `export_settings` (the settings Export for Screens last used) when that folder is outside them. The document itself is not changed. - `plugin.list`, `plugin.safeMode`, `file.recovery.*` and `ui_inspect`'s `scriptReport` leave out the paths outside the read roots, also those the person or an earlier request put there. In `plugin.list`, the plug-in From 73b06e478e51e29e9eb76b11a4c98c1ec194a0c5 Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Fri, 13 Nov 2026 04:24:17 +0530 Subject: [PATCH 92/92] UI: icon flyouts resize from their top edge and free top corner (storytold/craft-libs#1094) (#6913) Pin craft-ui to c4b8405d, which adds a top edge and the canvas-side top corner to icon-strip flyouts and saves the moved top per group. Accept its new MoveFlyoutTop docking action, and restore the saved top when a group returns to its place. A docking regression checks the cursors, an upward top-edge and free-corner drag with the bottom and icon-column edge fixed, and the top after reopening and in a saved layout. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Codex <codex@openai.com> --- Cargo.lock | 22 +++---- Cargo.toml | 2 +- crates/ui-egui/src/panel_docking.rs | 2 + crates/ui-egui/src/panel_docking/tests.rs | 70 +++++++++++++++++++++++ docs/gaps.md | 5 +- docs/roadmap.md | 5 +- docs/target-app-parity.md | 5 +- docs/ui-parity.md | 6 +- 8 files changed, 97 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a4eac55700..b42037f540 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,7 +217,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.61.2", + "windows-sys 0.52.0", "wl-clipboard-rs", "x11rb", ] @@ -986,12 +986,12 @@ source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7 [[package]] name = "craft-fonts-build" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=bb8320f5fa5d20fa78a026d0de06fe4e2f08a099#bb8320f5fa5d20fa78a026d0de06fe4e2f08a099" +source = "git+https://github.com/storytold/craft-libs?rev=c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6#c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6" [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=bb8320f5fa5d20fa78a026d0de06fe4e2f08a099#bb8320f5fa5d20fa78a026d0de06fe4e2f08a099" +source = "git+https://github.com/storytold/craft-libs?rev=c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6#c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1060,7 +1060,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=bb8320f5fa5d20fa78a026d0de06fe4e2f08a099#bb8320f5fa5d20fa78a026d0de06fe4e2f08a099" +source = "git+https://github.com/storytold/craft-libs?rev=c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6#c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6" dependencies = [ "craft-fonts-build", "craft-i18n", @@ -1620,7 +1620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3869,7 +3869,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4391,7 +4391,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4769,7 +4769,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4828,7 +4828,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5687,7 +5687,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.6", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7517,7 +7517,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ec55a218f0..5bed166e17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ repository = "https://github.com/storytold/vectorcraft" craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", default-features = false, features = ["interpret", "write"] } craft-print = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "bb8320f5fa5d20fa78a026d0de06fe4e2f08a099", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6", features = ["serde"] } craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } # Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 887c4cc83c..cc72f74b77 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -969,6 +969,7 @@ impl Transaction { if let Some(saved) = &location.group { apply(&mut candidate, Action::ResizeGroup { panel: panel.clone(), height: saved.height })?; apply(&mut candidate, Action::ResizeFlyoutWidth { panel: panel.clone(), width: saved.flyout_width })?; + apply(&mut candidate, Action::MoveFlyoutTop { panel: panel.clone(), offset: saved.flyout_offset })?; apply(&mut candidate, Action::SetMinimized { panel: panel.clone(), minimized: saved.minimized })?; } self.workspace.layout = candidate; @@ -1108,6 +1109,7 @@ fn known(action: &Action<String>) -> bool { | Action::ResizeColumn { panel, .. } | Action::ResizeGroup { panel, .. } | Action::ResizeFlyoutWidth { panel, .. } + | Action::MoveFlyoutTop { panel, .. } | Action::ResizeIconic { panel, .. } | Action::SetNative { panel, .. } | Action::SetMonitor { panel, .. } => registered(panel), diff --git a/crates/ui-egui/src/panel_docking/tests.rs b/crates/ui-egui/src/panel_docking/tests.rs index 3e2cf999e8..befffb1a9e 100644 --- a/crates/ui-egui/src/panel_docking/tests.rs +++ b/crates/ui-egui/src/panel_docking/tests.rs @@ -204,6 +204,76 @@ fn glyphs_and_artboards_flyouts_keep_user_sizes() { } } +/// An icon flyout's top edge and its free top corner (the canvas-side one) resize it upward: +/// the bottom and the icon-column edge stay, and the new top is kept when the flyout closes +/// and reopens and in a saved layout. The docking action that saves it is one VectorCraft +/// accepts (#1094). +#[test] +fn an_icon_flyout_grows_upward_from_its_top_edge_and_free_corner() { + assert!(known(&Action::MoveFlyoutTop { panel: "glyphs".into(), offset: Some(-40.0) })); + let mut h = Harness::new(); + let icon = h.hit(&Hit::Icon("glyphs".into())).unwrap().center(); + h.click(icon); + for _ in 0..30 { + h.frame(vec![]); + } + let rect = |h: &Harness| { + let id = area_id().with("flyout"); + let left = h.ctx.read_response(id.with("resize-left")).unwrap().rect; + let bottom = h.ctx.read_response(id.with("resize-bottom")).unwrap().rect; + let top = h.ctx.read_response(id.with("resize-top")).unwrap().rect; + Rect::from_min_max(pos2(left.left(), top.top()), pos2(bottom.right() + 12.0, bottom.bottom())) + }; + let before = rect(&h); + assert!(before.top() > 200.0, "room above the Glyphs flyout: {before:?}"); + for (at, cursor) in [ + (pos2(before.center().x, before.top() + 2.0), egui::CursorIcon::ResizeVertical), + (before.left_top() + vec2(2.0, 2.0), egui::CursorIcon::ResizeNwSe), + ] { + h.frame(vec![Event::PointerMoved(at)]); + h.frame(vec![]); + assert_eq!(h.cursor, cursor, "at {at:?}, frame {before:?}"); + } + let from = pos2(before.center().x, before.top() + 2.0); + h.drag(from, from - vec2(0.0, 80.0)); + for _ in 0..3 { + h.frame(vec![]); + } + let raised = rect(&h); + assert!((raised.top() - (before.top() - 80.0)).abs() < 1.0, "{before:?} -> {raised:?}"); + assert_eq!(raised.bottom(), before.bottom()); + assert_eq!(raised.right(), before.right(), "the icon-column edge stays anchored"); + let from = raised.left_top() + vec2(2.0, 2.0); + h.drag(from, from + vec2(-60.0, -20.0)); + for _ in 0..3 { + h.frame(vec![]); + } + let grown = rect(&h); + assert!((grown.width() - raised.width() - 60.0).abs() < 1.0, "{raised:?} -> {grown:?}"); + assert!((grown.top() - (raised.top() - 20.0)).abs() < 1.0, "{raised:?} -> {grown:?}"); + assert_eq!(grown.bottom(), before.bottom()); + assert_eq!(grown.right(), before.right()); + h.click(icon); + assert_eq!(craft_ui::docking::flyout::<String>(&h.ctx, area_id()), None); + h.click(icon); + for _ in 0..3 { + h.frame(vec![]); + } + assert_eq!(rect(&h), grown, "reopened where it was"); + let saved = serde_json::to_string(&h.layout()).unwrap(); + assert!(saved.contains("flyout_offset")); + let mut restored = Harness::new(); + restored.app.ui.docking = Some(serde_json::from_str(&saved).unwrap()); + for _ in 0..3 { + restored.frame(vec![]); + } + restored.click(restored.hit(&Hit::Icon("glyphs".into())).unwrap().center()); + for _ in 0..3 { + restored.frame(vec![]); + } + assert_eq!(rect(&restored), grown, "saved layout"); +} + /// Footer controls stay at the resized panel's bottom, including empty or short lists. /// The same shared footer is used by the other icon panels and library windows. #[test] diff --git a/docs/gaps.md b/docs/gaps.md index 826de6fe24..782d6a27b8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (#6865: oversized editing artboard import; Glyphs panel: Alternates for Current Selection applies an OpenType feature value (`salt=2`) to the selected letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: oversized editing artboard import; Glyphs panel: Alternates for Current Selection applies an OpenType feature value (`salt=2`) to the selected letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -8,7 +8,7 @@ Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. -Icon-strip flyouts resize from the canvas-side edge and bottom, keeping the icon-column edge anchored (#5713). Independent group heights and flyout widths persist in workspaces; saved heights override content fitting while untouched heights still fit content. Shared panel button bars and the Glyphs font row stay at the bottom after enlargement; the Artboards list grows into the extra space. Regressions: `panel_docking::tests::glyphs_and_artboards_flyouts_keep_user_sizes` and `expanded_panel_footers_stay_at_the_bottom_with_short_and_long_lists`. Scores and estimates unchanged. +Icon-strip flyouts resize from the canvas-side edge, the top, the bottom and the corners on the canvas side, keeping the icon-column edge anchored (#5713, storytold/craft-libs#1094); a top-edge resize keeps the bottom and saves the moved top per group in workspaces. Independent group heights and flyout widths persist in workspaces; saved heights override content fitting while untouched heights still fit content. Shared panel button bars and the Glyphs font row stay at the bottom after enlargement; the Artboards list grows into the extra space. Regressions: `panel_docking::tests::glyphs_and_artboards_flyouts_keep_user_sizes` and `expanded_panel_footers_stay_at_the_bottom_with_short_and_long_lists`. Scores and estimates unchanged. Group Selection keeps an already selected component selected during a drag; a completed click still adds its next enclosing group. The current Direct Selection click/release machinery is shared, preserving compound-member picks, Shift deselection, isolation and masked-art dragging. Outline Stroke continues to preserve all original fills, including open contours; the change does not remove fills. Regressions: `group_selection_distinguishes_selected_leaf_drags_from_clicks` and `outline_fill_and_stroke_move_independently_with_group_selection` cover tool events, independent component movement and Undo/Redo. Scores and estimates unchanged. @@ -39,6 +39,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 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 | G9: EPS import reads gradients a page paints as abutting strips as one linear gradient each (no seams); an Illustrator EPS whose page drew them that way keeps its layers. Estimate unchanged. | | 2026-11-06 | minor | Opening a file that takes longer than 0.4 s shows a progress window: the file name, a moving bar (reading reports no steps), how many more files wait, Cancel (Esc; the file doesn't open) and Continue in Background (`file.cancelOpen`, `file.openInBackground`); it never opens over another dialog or progress window (part of #5930); scores and estimates unchanged | +| 2026-11-12 | minor | Icon-strip flyouts also resize from the top edge and the canvas-side top corner; the moved top is kept per group in workspaces (storytold/craft-libs#1095). Scores and estimates unchanged. | | 2026-11-06 | minor | Icon-strip flyouts resize on their free edges and retain sizes with workspaces (#5713); saved heights beat auto-fit; panel footers stay at the bottom after enlargement. Scores and estimates unchanged. | | 2026-11-05 | minor | G15: long open strokes with an undashed width profile are outlined near the view while keeping profile positions on the full path; a 200,000-segment outline took 59.95 ms before and 8.44 ms after in local median runs. Closed and dashed profile strokes remain whole; estimate unchanged. | | 2026-11-05 | minor | G15: Direct Selection reuses one document hit result when choosing the cursor over a path; a 200,000-anchor path's segment cursor took 58.15 ms before and 29.33 ms after in local median runs. Cursor behavior is unchanged; estimate unchanged. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 2360cb7bb0..4c78d258c9 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-12 · **Change:** minor (#6865: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -18,7 +18,7 @@ Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. -Icon-strip flyouts resize from the canvas-side edge and bottom, keeping the icon-column edge anchored (#5713). Independent group heights and flyout widths persist in workspaces; saved heights override content fitting while untouched heights still fit content. Shared panel button bars and the Glyphs font row stay at the bottom after enlargement; the Artboards list grows into the extra space. Regressions: `panel_docking::tests::glyphs_and_artboards_flyouts_keep_user_sizes` and `expanded_panel_footers_stay_at_the_bottom_with_short_and_long_lists`. Scores and estimates unchanged. +Icon-strip flyouts resize from the canvas-side edge, the top, the bottom and the corners on the canvas side, keeping the icon-column edge anchored (#5713, storytold/craft-libs#1094); a top-edge resize keeps the bottom and saves the moved top per group in workspaces. Independent group heights and flyout widths persist in workspaces; saved heights override content fitting while untouched heights still fit content. Shared panel button bars and the Glyphs font row stay at the bottom after enlargement; the Artboards list grows into the extra space. Regressions: `panel_docking::tests::glyphs_and_artboards_flyouts_keep_user_sizes` and `expanded_panel_footers_stay_at_the_bottom_with_short_and_long_lists`. Scores and estimates unchanged. **PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged. @@ -59,6 +59,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | 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 | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; 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-12 | minor | Icon-strip flyouts also resize from the top edge and the canvas-side top corner; the moved top is kept per group in workspaces (storytold/craft-libs#1095). Scores and estimates unchanged. | | 2026-11-06 | minor | Icon-strip flyouts resize on their free edges and retain sizes with workspaces (#5713); saved heights beat auto-fit; panel footers stay at the bottom after enlargement. 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 23080e57ee..f51436bc96 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-12 · **Change:** minor (#6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -20,7 +20,7 @@ Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. -Icon-strip flyouts resize from the canvas-side edge and bottom, keeping the icon-column edge anchored (#5713). Independent group heights and flyout widths persist in workspaces; saved heights override content fitting while untouched heights still fit content. Shared panel button bars stay at the bottom after enlargement. Regressions: `panel_docking::tests::glyphs_and_artboards_flyouts_keep_user_sizes` and `expanded_panel_footers_stay_at_the_bottom_with_short_and_long_lists`. Scores and estimates unchanged. +Icon-strip flyouts resize from the canvas-side edge, the top, the bottom and the corners on the canvas side, keeping the icon-column edge anchored (#5713, storytold/craft-libs#1094); a top-edge resize keeps the bottom and saves the moved top per group in workspaces. Independent group heights and flyout widths persist in workspaces; saved heights override content fitting while untouched heights still fit content. Shared panel button bars stay at the bottom after enlargement. Regressions: `panel_docking::tests::glyphs_and_artboards_flyouts_keep_user_sizes` and `expanded_panel_footers_stay_at_the_bottom_with_short_and_long_lists`. Scores and estimates unchanged. **PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged. @@ -69,6 +69,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-11-07 | minor | Type: paint-only Arabic harakat keep joining forms; a matching character-level pass assigns a later in-cluster run only for GDEF mark glyphs on harakat characters (otherwise cluster start); Insert Kashidas tatweels take the base letter's paint (`tests::harakat`). Scores and estimates unchanged. Marks may retain letter paint without GDEF mark classes or with some AAT/`morx` ligatures. | | 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. | | 2026-11-06 | minor | Width profiles: the width between two width points follows a smooth curve instead of a straight line, so a stroke no longer kinks at each width point (#5714). Each side is a monotone cubic through its points: flat at a widest or narrowest point, never past either point of a span; stacked points still step. Straight runs are sampled between width points to the drawing tolerance. Scores and estimates unchanged. | +| 2026-11-12 | minor | Icon-strip flyouts also resize from the top edge and the canvas-side top corner; the moved top is kept per group in workspaces (storytold/craft-libs#1095). Scores and estimates unchanged. | | 2026-11-06 | minor | Icon-strip flyouts resize on their free edges and retain sizes with workspaces (#5713); saved heights beat auto-fit; panel footers stay at the bottom after enlargement. Scores and estimates unchanged. | | 2026-11-05 | minor | A nearby hit on a plain long stroke or outline avoids assembling its whole path. A hit near the middle of a 200,000-anchor path took about 11–13 ms before and 1.0–1.4 ms after in paired runs on a busy Windows machine; a miss inside the path's box fell from 18.7 ms to 1.6 ms, while a distant miss was unchanged. Other stroke styles and fill hits keep their existing checks. Scores and estimates unchanged; idle performance budgets remain unverified. | | 2026-11-05 | minor | Translating a type object without changing its text or formatting reuses glyph geometry and expanded character-stroke outlines between frames. The text appears at its new position with unchanged pixels. Scaling the type, editing its content or formatting, or changing installed fonts rebuilds the geometry; scores and estimates unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 423dd1af28..0f9beab5be 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -15,7 +15,7 @@ Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow | 2026-11-06 | minor | Opening a file that takes longer than 0.4 s shows a progress window: the file name, a moving bar (reading reports no steps), how many more files wait, Cancel (Esc; the file doesn't open) and Continue in Background (`file.cancelOpen`, `file.openInBackground`); it never opens over another dialog or progress window (part of #5930); scores and estimates unchanged | Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. -Icon-strip flyouts resize from the canvas-side edge and bottom, keeping the icon-column edge anchored (#5713). Independent group heights and flyout widths persist in workspaces; saved heights override content fitting while untouched heights still fit content. Shared panel button bars and the Glyphs font row stay at the bottom after enlargement; the Artboards list grows into the extra space. Regressions: `panel_docking::tests::glyphs_and_artboards_flyouts_keep_user_sizes` and `expanded_panel_footers_stay_at_the_bottom_with_short_and_long_lists`. Scores and estimates unchanged. +Icon-strip flyouts resize from the canvas-side edge, the top, the bottom and the corners on the canvas side, keeping the icon-column edge anchored (#5713, storytold/craft-libs#1094); a top-edge resize keeps the bottom and saves the moved top per group in workspaces. Independent group heights and flyout widths persist in workspaces; saved heights override content fitting while untouched heights still fit content. Shared panel button bars and the Glyphs font row stay at the bottom after enlargement; the Artboards list grows into the extra space. Regressions: `panel_docking::tests::glyphs_and_artboards_flyouts_keep_user_sizes` and `expanded_panel_footers_stay_at_the_bottom_with_short_and_long_lists`. Scores and estimates unchanged. **PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged. @@ -62,6 +62,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-11-06 | minor | Shape tools: a press on the selection's centre point (Show Center) moves the selection, also where no art is painted, and a press in the rotate zone just outside a bounding-box corner turns it about the centre, both as the Selection tool's do (#5477); the Selection tool takes its centre point too, so an unfilled shape moves instead of starting a marquee; scores and estimates unchanged | | 2026-11-06 | minor | Transform fields: one Tab moves from one numeric field to the next in the Transform panel, the Properties panel's Transform section and the Control bar — a field's scrub label was a second, invisible tab stop, and the W/H link between W and H is now made after the fields, so it stays a tab stop after them; scores and estimates unchanged | | 2026-11-06 | minor | Scrubby labels: the Control bar's Stroke: and Opacity: and the Properties panel's Stroke, links to their panels, scrub the field after them as other labels do; a click still opens the panel (#4088); scores and estimates unchanged | +| 2026-11-12 | minor | Icon-strip flyouts also resize from the top edge and the canvas-side top corner; the moved top is kept per group in workspaces (storytold/craft-libs#1095). Scores and estimates unchanged. | | 2026-11-06 | minor | Icon-strip flyouts resize on their free edges and retain sizes with workspaces (#5713); saved heights beat auto-fit; panel footers stay at the bottom after enlargement. Scores and estimates unchanged. | | 2026-11-05 | minor | Direct Selection: Shift-click picks several points of a blend's spine and dragging one moves them all, with the key objects on them, in one undo step (#5285); scores and estimates unchanged | | 2026-11-05 | minor | Direct Selection: Cmd/Ctrl held lends the Selection tool, whose bounding box shows while the key is down over objects selected whole; its handles scale and rotate, and a Cmd drag moves the whole object with its direct-selected anchors kept (#3901); scores and estimates unchanged | @@ -163,6 +164,7 @@ tools, never checked side by side) isn't captured row by row. | Each anchor snaps to Smart Guides and construction angles | done | `DrawSnap` | | Curvature tool: click/drag points, double-click or Alt-click for a corner, temporary Direct Selection, point conversion | partial | circular point markers without direction handles or duplicate markers, Anchor Size and Hide Edges regression-tested (#1236, in part); curve geometry: each stretch of three points follows the circle through them (handles along its tangent, chord/(3·cos²(α/2)) long, shorter in a turn tighter than 30°), open ends curving in, the same rule for the rubber band, dragged, inserted and converted points (#1236, in part); neighbours of a point dragged on an existing path keep their tangents, and modifiers requested in #975 remain | | Icon-strip flyout resizing (#5713) | done | Canvas-side edge and bottom; user size persists after reopening and workspace restore; Glyphs and Artboards pointer/cursor regression | +| Icon-strip flyout resizing from the top (storytold/craft-libs#1094) | done | Top edge (a 4 pt band over the tab strip) and the canvas-side top corner; the bottom and the icon-column edge stay; the moved top persists after reopening and workspace restore; `an_icon_flyout_grows_upward_from_its_top_edge_and_free_corner` | | Layers panel lists a blend's spine (#4820) | done | an open blend shows a `<Path>` row below its key objects, where Release leaves the spine; a click selects the blend (Shift/Cmd toggles it) so Direct Selection edits the spine; a locked blend's row selects nothing; no row while searching or filtering (`a_blends_spine_is_a_row_below_its_keys_that_selects_the_blend`) | | Layers panel: a layer whose Layer Options › Print is off shows its name italic (#5739) | done | the panel row and its drag ghost; a printing layer stays upright, and Print on again restores it; `panels::layers` test `a_layer_that_does_not_print_has_its_name_italic` | | Blend spine Anchor Point conversion and handle editing (#2544) | implemented | symmetric handles, independent drag/retraction, visible-handle policy and selection/stacking covered by tool and engine regressions; Windows native canvas checks and screenshots reviewed in PR #2643 |