diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 2cffad6581..27d3194eb3 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -26,20 +26,20 @@ use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Vec2}; use crate::distort::{transform_move, transform_offsets}; use crate::group::{has_own_paint, paints}; -use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; +use crate::reshape::{as_art, clipping_text_outline, 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_instance_row_raster, has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, outline_text, reshape, + has_instance_row_raster, has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, reshape, }; -fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { +pub(crate) fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { match item { AppearanceItem::Fill(f) => &f.effects, AppearanceItem::Stroke(s) => &s.effects, } } -fn clear_item_effects(item: &mut AppearanceItem) { +pub(crate) fn clear_item_effects(item: &mut AppearanceItem) { match item { AppearanceItem::Fill(f) => f.effects = crate::raster::without_geometry(&f.effects), AppearanceItem::Stroke(s) => s.effects = crate::raster::without_geometry(&s.effects), @@ -74,7 +74,7 @@ fn geometry(n: &Node) -> Option<(PathData, FillRule)> { /// `path` with the geometry effects of `effects` applied, and how gradients follow them /// ([`crate::Carry`]). -fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext) -> (PathData, crate::Carry) { +pub(crate) fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext) -> (PathData, crate::Carry) { if !has_geometry(effects) || path.is_empty() { return (path.clone(), crate::Carry::default()); } @@ -86,7 +86,7 @@ fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext /// context of) applied, and `carried` (`item` as the object's effects left it) with its gradient /// moved along with them and the Transform effects the geometry pass left out hidden /// ([`crate::Carry::hide_skipped`]). -fn apply_item(item: &AppearanceItem, carried: &AppearanceItem, g: &PathData, ctx: &GeomContext) -> (PathData, AppearanceItem) { +pub(crate) fn apply_item(item: &AppearanceItem, carried: &AppearanceItem, g: &PathData, ctx: &GeomContext) -> (PathData, AppearanceItem) { let (ig, carry) = apply(item_effects(item), g, &ctx.item(item)); let mut ap = Appearance { items: vec![carried.clone()], ..Default::default() }; if carry.moves(&ap) { @@ -202,7 +202,22 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { return bake_leaf(d, n); } let mut m = n.clone(); - let baked: Vec> = ch.iter().map(|c| bake_node(d, c).map(Arc::new).unwrap_or_else(|| c.clone())).collect(); + // A clipping path takes the geometry effects in it as the canvas clips by it, those of a + // compound path's paths before the compound path's own ([`crate::clip::with_clip_effects_applied`]); + // the pieces it is made of then get ids of their own. + let clip = usize::from(n.clips()); + let baked: Vec> = ch + .iter() + .enumerate() + .map(|(i, c)| match (i < clip).then(|| crate::clip::with_clip_effects_applied(c)).flatten() { + Some(a) => { + let mut a = bake_node(d, &a).unwrap_or(a); + fresh_ids(d, &mut a, &mut std::collections::HashSet::new()); + Arc::new(a) + } + None => bake_node(d, c).map(Arc::new).unwrap_or_else(|| c.clone()), + }) + .collect(); if let Some(ch) = m.children_mut() { *ch = baked; } @@ -407,7 +422,7 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option { last => Some(Node::group(c.id, copies.into_iter().chain(last).map(Arc::new).collect())), } } - None if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }), + None if clipping => clipping_text_outline(c), None => as_art(Some(d), c, None), }; if let Some(o) = art { @@ -469,7 +484,7 @@ fn piece(d: &mut Document, n: &Node, path: PathData, rule: FillRule, item: Appea /// `n`'s geometry with its object-level geometry effects applied, its fill rule, the context of /// its effects, and `n` without those effects, its gradients moved along with them. -fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> { +pub(crate) fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> { let (base, rule) = geometry(n)?; let ctx = GeomContext::of(n); let mut m = n.clone(); diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index f3855f57ca..d2ba694149 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -793,7 +793,7 @@ fn type_outlined_next_to_type_that_stays_type_keeps_its_look() { } #[test] -fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() { +fn type_in_a_clipping_group_with_a_fill_of_its_own_keeps_its_effect() { if skip_without_craft_fonts() { return; } @@ -808,9 +808,9 @@ fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() { g.children_mut().unwrap().push(Arc::new(clip)); let d = bake_document(&doc_with(g)).unwrap(); assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]); - // The type clips by its plain outlines, as the canvas clips by them: its own effect is left out. + // The type clips by its outlines moved by its own effect, as the canvas clips by them. let b = d.node(NodeId(59)).and_then(|n| n.geometric_bounds()).unwrap(); - assert!(b.y0 > 30.0 && b.y1 < 70.0, "{b:?}"); + assert!(b.y0 > 330.0 && b.y1 < 370.0, "{b:?}"); } #[test] diff --git a/crates/effects/src/clip.rs b/crates/effects/src/clip.rs index a3e31f1842..60cc3f2845 100644 --- a/crates/effects/src/clip.rs +++ b/crates/effects/src/clip.rs @@ -7,9 +7,124 @@ use vectorcraft_geom::{BezPath, FillRule, PathData}; /// The clipping path `clip` of a clip group as one path and fill rule: compound paths keep their /// holes, even-odd paths their rule, text clips by its glyph outlines and a group by the union of -/// its members. `None` when there is nothing to clip by (the clipped art is then hidden). +/// its members. The live geometry effects of the clipping path (and of the members it clips by) +/// reshape the region, as they reshape what it paints, and so do the geometry effects of a path's +/// fills and strokes: the region is then the union of the paths they give, as an export bakes them. +/// `None` when there is nothing to clip by (the clipped art is then hidden). pub fn clip_outline(clip: &Node) -> Option<(BezPath, FillRule)> { - clip.clip_outline(Some(&outline_text), &unite) + match clip_effects_applied(clip, true) { + Some(m) => m.clip_outline(Some(&outline_text), &unite), + None => clip.clip_outline(Some(&outline_text), &unite), + } +} + +/// `n`, the clipping path of a clip group, with the live geometry effects of everything it clips by +/// applied, innermost first, and removed: a path or compound path with effects becomes a path of +/// its reshaped geometry that keeps its fills, strokes and other effects (its gradients follow the +/// effects, as an export bakes it), the paths of a compound path and the visible members of a group +/// are reshaped by their own effects before the effects around them, a group's or layer's own +/// effects reshape its members as an export evaluates them, and other objects with effects (type) +/// become a path of their reshaped region. A path or compound path whose fills or strokes have +/// geometry effects becomes a group of one path per fill and stroke, reshaped by the path's effects +/// and then by that fill's or stroke's, as an export bakes it; [`clip_outline`] clips by their +/// union. A clipping path that paints ([`Node::clip_paint`], which a group does not) keeps the +/// geometry effects of its own fills and strokes live instead. `None` when nothing in it has a +/// geometry effect. Effects on the clip group or around it then reshape the result, after its own. +pub(crate) fn with_clip_effects_applied(n: &Node) -> Option { + let paint = n.clip_paint(); + clip_effects_applied(n, paint.fill.is_none() && paint.stroke.is_none()) +} + +/// [`with_clip_effects_applied`]; with `items` unset (a clipping path that paints, and the paths of +/// a compound path, which draw with the compound path's fills and strokes), the geometry effects of +/// `n`'s own fills and strokes are left out. +fn clip_effects_applied(n: &Node, items: bool) -> Option { + let mut m: Option = None; + let members: &[std::sync::Arc] = match &n.kind { + NodeKind::Group { children, clip: true } => children.get(..1).unwrap_or_default(), + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } => children, + _ => &[], + }; + // A group or layer clips by its visible members after its shaper; a compound path by all of + // its paths, and a nested clip group by its clipping path, shown or hidden + // ([`Node::clip_shapes`]). + let all = matches!(n.kind, NodeKind::Compound { .. } | NodeKind::Group { clip: true, .. }); + let skip = usize::from(matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. }) && n.extras().shaper.is_some()); + // The paths of a compound path draw with the compound path's fills and strokes, and stay paths. + let compound = matches!(n.kind, NodeKind::Compound { .. }); + for (i, c) in members.iter().enumerate().skip(skip).filter(|(_, c)| all || c.visible) { + if let Some(r) = clip_effects_applied(c, items && !compound) + && let Some(slot) = m.get_or_insert_with(|| n.clone()).children_mut().and_then(|ch| ch.get_mut(i)) + { + *slot = std::sync::Arc::new(r); + } + } + let items = items + && matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) + && n.appearance.items.iter().any(|i| crate::has_geometry(crate::bake::item_effects(i))); + if items && let Some((g, rule, ctx, mut p)) = crate::bake::leaf_base(m.as_ref().unwrap_or(n)) { + let children = n + .appearance + .items + .iter() + .zip(&p.appearance.items) + .map(|(item, carried)| { + let (ig, mut it) = crate::bake::apply_item(item, carried, &g, &ctx); + crate::bake::clear_item_effects(&mut it); + let mut piece = Node::path(n.id, ig, Appearance { items: vec![it], ..Default::default() }); + if let NodeKind::Path { rule: r, .. } = &mut piece.kind { + *r = rule; + } + std::sync::Arc::new(piece) + }) + .collect(); + // The path's transparency and raster effects stay on the group of its pieces. + p.appearance.items.clear(); + p.kind = NodeKind::Group { children, clip: false }; + return Some(p); + } + if !crate::has_geometry(&n.appearance.effects) { + return m; + } + let cur = m.as_ref().unwrap_or(n); + // A group's or layer's effects reshape each of its members (a clip group's its clipping + // path), as an export evaluates them; otherwise they reshape its region, below. + if matches!(cur.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) + && let Some(e) = crate::group::evaluate_container(None, cur) + { + return Some(e); + } + // A path or compound path, as an export bakes it. + if let Some((g, rule, _, mut p)) = crate::bake::leaf_base(cur) { + let clipping = match &cur.kind { + NodeKind::Path { clipping, .. } => *clipping, + NodeKind::Compound { children, .. } => children.iter().any(|c| matches!(c.kind, NodeKind::Path { clipping: true, .. })), + _ => false, + }; + p.kind = Node::path(n.id, g, Appearance::default()).kind; + if let NodeKind::Path { rule: r, clipping: c, .. } = &mut p.kind { + (*r, *c) = (rule, clipping); + } + return Some(p); + } + // Type, as its painted outlines that its effects reshape as an export evaluates them. + if matches!(cur.kind, NodeKind::Text(_)) + && let Some(mut o) = crate::outline_text(cur) + { + o.visible = n.visible; + o.appearance.effects = n.appearance.effects.clone(); + if let Some(e) = crate::group::evaluate_container(None, &o) { + return Some(e); + } + } + let (bp, rule) = cur.clip_outline(Some(&outline_text), &unite)?; + let (g, _) = crate::bake::apply(&n.appearance.effects, &PathData::from_bezpath(&bp), &crate::GeomContext::of(n)); + let mut p = Node::path(n.id, g, Appearance::default()); + if let NodeKind::Path { rule: r, .. } = &mut p.kind { + *r = rule; + } + p.visible = n.visible; + Some(p) } /// The knockout shape of a piece of a mesh-warped image ([`Node::knockout_outline`]) painted opaque diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 4df0a105ce..0db50ef925 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -845,9 +845,10 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { /// Replace each visible member of `n` with geometry or Pathfinder effects of its own, at every /// depth of its groups and layers, by the art it draws ([`evaluate_container`], /// [`crate::reshape::own_effects_art`]), so the geometry effects of `n` reshape what its members -/// draw. A clip group's clipping path stays as it is, and so do images, symbol instances and the -/// other objects that `own_effects_art` leaves alone, and a group or layer with a 3D effect with -/// everything in it. Calls nested in one another, through the +/// draw. A clip group's clipping path only takes the geometry effects in it into its geometry +/// ([`crate::clip::with_clip_effects_applied`]). Images, symbol instances and the other objects +/// that `own_effects_art` leaves alone stay as they are, and so does a group or layer with a 3D +/// effect with everything in it. Calls nested in one another, through the /// groups and layers it walks and the art it evaluates (symbol art and Pathfinder members /// included), stop at [`vectorcraft_doc::depth::MAX_DEPTH`] deep, and together they replace at /// most [`crate::SYMBOL_ART_MAX_NODES`] members: past either limit, members stay as they are. @@ -855,6 +856,13 @@ pub(crate) fn members_as_drawn(doc: Option<&Document>, n: &mut Node) { let Some(_frame) = DrawnFrame::enter() else { return }; let clip = usize::from(n.clips()); let Some(ch) = n.children_mut() else { return }; + // The clipping path's own geometry effects shape it before the effects around it do. + if clip == 1 + && let Some(first) = ch.first_mut() + && let Some(applied) = crate::clip::with_clip_effects_applied(first) + { + *first = Arc::new(applied); + } for c in ch.iter_mut().skip(clip).filter(|c| c.visible) { if !DrawnFrame::take_member() { return; diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 8e00718c01..947bf262f5 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -747,9 +747,9 @@ fn a_baked_group_that_keeps_its_type_applies_its_members_effects_first() { assert!((w - 250.0).abs() < 1e-6, "{w}"); } -/// A clip group's clipping path keeps its own effects; the clipped members take theirs first. +/// A clip group's clipping path and the clipped members take their own effects first. #[test] -fn a_clipping_path_keeps_its_own_effects_under_a_groups_effects() { +fn a_clipping_path_takes_its_own_effects_before_a_groups_effects() { let mut clip = with_effect(square_at_100(), offset_10()); clip.id = NodeId(2); if let NodeKind::Path { clipping, .. } = &mut clip.kind { @@ -762,7 +762,9 @@ fn a_clipping_path_keeps_its_own_effects_under_a_groups_effects() { let clip_group = art.children().unwrap()[0].clone(); let ch = clip_group.children().unwrap(); assert!(matches!(ch[0].kind, NodeKind::Path { clipping: true, .. }), "{:?}", ch[0].kind); - assert_eq!(crate::reshape::geometry_effects(&ch[0]).len(), 1, "the clipping path keeps its Offset Path"); + assert!(crate::reshape::geometry_effects(&ch[0]).is_empty(), "the clipping path's Offset Path is applied"); + let w = ch[0].geometric_bounds().unwrap().width(); + assert!((w - 60.0).abs() < 0.5, "grown by 10 on each side, then scaled to 50 %: {w}"); assert!(matches!(ch[1].kind, NodeKind::Group { .. }) && crate::reshape::geometry_effects(&ch[1]).is_empty(), "the member is drawn as its art"); } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 83ff68431f..ee7d3f8322 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -689,18 +689,28 @@ fn instances_as_art(doc: Option<&Document>, n: &mut Node, outline: OutlineHook, } } +/// Type `t` in a clip group's clipping path as its outlines ([`outline_text`]) with its +/// visibility, or with geometry effects as a path of the region they give it, which is what +/// [`crate::clip_outline`] clips by. +pub(crate) fn clipping_text_outline(t: &Node) -> Option { + if let Some(p) = crate::clip::with_clip_effects_applied(t) { + return Some(p); + } + let o = outline_text(t)?; + Some(Node { visible: t.visible, ..o }) +} + /// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look /// ([`as_art`]). Type in a clip group's clipping path (all of `n` when `clipping` is set) becomes -/// its plain outlines ([`outline_text`]) with its visibility, which is what -/// [`crate::clip_outline`] clips by. The swatches and patterns that live-effect plug-ins painting -/// their pieces name are looked up in `doc`. +/// its outlines with its visibility and effects ([`clipping_text_outline`]). The swatches and +/// patterns that live-effect plug-ins painting their pieces name are looked up in `doc`. pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping: bool) { let clips = n.clips(); let Some(ch) = n.children_mut() else { return }; for (i, c) in ch.iter_mut().enumerate() { let clipping = clipping || (clips && i == 0); let art = match &c.kind { - NodeKind::Text(_) if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }), + NodeKind::Text(_) if clipping => clipping_text_outline(c), NodeKind::Text(_) => as_art(doc, c, None), _ => None, }; diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 0023fc5b16..c43d383c19 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 clipfx; mod imageoutline; mod painted; mod rasterfollow; diff --git a/crates/effects/src/tests/clipfx.rs b/crates/effects/src/tests/clipfx.rs new file mode 100644 index 0000000000..370f32fa81 --- /dev/null +++ b/crates/effects/src/tests/clipfx.rs @@ -0,0 +1,401 @@ +//! Live effects on the clipping path of a clip group: the region it clips to takes the shape its +//! geometry effects give it, on the canvas as in an export. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId, NodeKind}; +use vectorcraft_geom::{Rect, Shape, shapes}; + +use crate::{bake_document, clip_outline}; + +fn fx(id: &str, params: Value) -> Effect { + Effect { id: id.into(), params, visible: true } +} + +/// A 100 pt square clipping path at (50, 50) with `effects`. +fn clipping_path(id: u64, effects: Vec) -> Node { + let mut p = Node::path(NodeId(id), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut p.kind { + *clipping = true; + } + p.appearance.effects = effects; + p +} + +/// A clip group: `clip` clipping a red 200 pt square at the origin. +fn clip_group(id: u64, clip: Node) -> Node { + let art = Node::path( + NodeId(id + 100), + shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), + Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0), + ); + Node::new(NodeId(id), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true }) +} + +fn bounds(n: &Node) -> Rect { + clip_outline(n).map(|(bp, _)| bp.bounding_box()).unwrap() +} + +/// The clipping path of the first clip group in `doc` after an export bakes it. +fn baked_clip(doc: &Document) -> Arc { + fn first(n: &Arc) -> Option> { + match &n.kind { + NodeKind::Group { children, clip: true } => children.first().cloned(), + _ => n.children()?.iter().find_map(first), + } + } + let baked = bake_document(doc).unwrap(); + baked.layers.iter().find_map(first).unwrap() +} + +#[test] +fn transform_moves_the_clip() { + let clip = clipping_path(1, vec![fx("distort.transform", json!({"moveH": 60}))]); + let b = bounds(&clip); + assert!((b.x0 - 110.0).abs() < 1e-6 && (b.x1 - 210.0).abs() < 1e-6, "the clip moves with the clipping path: {b:?}"); + assert!((b.y0 - 50.0).abs() < 1e-6 && (b.y1 - 150.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn roughen_clips_as_an_export_does() { + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + let group = clip_group(1, clipping_path(2, vec![fx("distort.roughen", json!({"size": 10, "detail": 10, "relative": false}))])); + d.insert(Some(layer), 0, group.clone()).unwrap(); + let NodeKind::Group { children, .. } = &group.kind else { panic!("not a group") }; + let live = clip_outline(&children[0]).unwrap(); + let exported = clip_outline(&baked_clip(&d)).unwrap(); + assert_eq!(live.1, exported.1); + assert_eq!(live.0, exported.0, "the canvas clips by the outline the export writes"); + assert_ne!(live.0.bounding_box(), Rect::new(50.0, 50.0, 150.0, 150.0), "the roughened outline, not the plain square"); +} + +#[test] +fn nested_members_keep_their_effects() { + // A group as the clipping path: its member's Transform effect moves its part of the region. + let moved = clipping_path(2, vec![fx("distort.transform", json!({"moveV": 60}))]); + let group = Node::group(NodeId(1), vec![Arc::new(moved)]); + let b = bounds(&group); + assert!((b.y0 - 110.0).abs() < 1e-6 && (b.y1 - 210.0).abs() < 1e-6, "{b:?}"); + // A clip group as the clipping path clips by its own clipping path, effects and all. + let nested = clip_group(3, clipping_path(4, vec![fx("distort.transform", json!({"moveH": -30}))])); + let b = bounds(&nested); + assert!((b.x0 - 20.0).abs() < 1e-6 && (b.x1 - 120.0).abs() < 1e-6, "{b:?}"); + // So does the nested clip group's own effect. + let mut nested = clip_group(7, clipping_path(8, vec![])); + nested.appearance.effects.push(fx("distort.transform", json!({"moveH": 30}))); + let b = bounds(&nested); + assert!((b.x0 - 80.0).abs() < 1e-6 && (b.x1 - 180.0).abs() < 1e-6, "{b:?}"); + // The group's own Transform effect applies on top of its members'. + let mut group = Node::group(NodeId(5), vec![Arc::new(clipping_path(6, vec![fx("distort.transform", json!({"moveV": 60}))]))]); + group.appearance.effects.push(fx("distort.transform", json!({"moveH": 10}))); + let b = bounds(&group); + assert!((b.x0 - 60.0).abs() < 1e-6 && (b.y0 - 110.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn a_roughened_group_clips_as_an_export_does() { + // Two members side by side, Roughen on the group: each member is roughened, as in the export. + let member = |id: u64, x: f64| { + Arc::new(Node::path( + NodeId(id), + shapes::rectangle(Rect::new(x, 50.0, x + 40.0, 150.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + )) + }; + let mut clip = Node::group(NodeId(2), vec![member(3, 50.0), member(4, 110.0)]); + clip.appearance.effects.push(fx("distort.roughen", json!({"size": 10, "detail": 10, "relative": false}))); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, clip_group(1, clip.clone())).unwrap(); + let live = clip_outline(&clip).unwrap(); + assert_eq!(live.0, clip_outline(&baked_clip(&d)).unwrap().0, "the canvas clips by the outline the export writes"); + assert_ne!(live.0.bounding_box(), Rect::new(50.0, 50.0, 150.0, 150.0)); +} + +#[test] +fn a_compound_paths_member_keeps_its_effect() { + // A ring whose hole a Transform effect moves 20 pt right, as the export bakes it. + let ring = |effects: Vec| { + let outer = Node::path(NodeId(3), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), Appearance::default()); + let mut hole = Node::path(NodeId(4), shapes::rectangle(Rect::new(75.0, 75.0, 125.0, 125.0)), Appearance::default()); + hole.appearance.effects = effects; + Node::new(NodeId(2), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(hole)], rule: vectorcraft_geom::FillRule::EvenOdd }) + }; + let moved = ring(vec![fx("distort.transform", json!({"moveH": 20}))]); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, clip_group(1, moved.clone())).unwrap(); + let live = clip_outline(&moved).unwrap(); + assert_eq!(live, clip_outline(&baked_clip(&d)).unwrap(), "the canvas clips by the outline the export writes"); + assert_ne!(live, clip_outline(&ring(vec![])).unwrap(), "the hole moves"); + // With a Transform of the compound path's own moving the whole ring 10 pt down after it. + let mut both = moved.clone(); + both.appearance.effects.push(fx("distort.transform", json!({"moveV": 10}))); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, clip_group(1, both.clone())).unwrap(); + let live = clip_outline(&both).unwrap(); + assert_eq!(live, clip_outline(&baked_clip(&d)).unwrap(), "the canvas clips by the outline the export writes"); + assert_ne!(live.0.bounding_box(), clip_outline(&moved).unwrap().0.bounding_box(), "the ring moves down"); +} + +#[test] +fn the_clipping_paths_effects_apply_before_the_clip_groups() { + // Scale 50 % on the clipping path, then Offset Path 10 on its clip group: the region is the + // half-size square grown by 10, as if the clipping path had been drawn at half size. + let group = |clip: Node| { + let mut g = clip_group(1, clip); + g.appearance.effects.push(fx("path.offsetPath", json!({"offset": 10}))); + g + }; + let scaled = group(clipping_path(2, vec![fx("distort.transform", json!({"scaleH": 50, "scaleV": 50}))])); + let mut half = clipping_path(2, vec![]); + if let NodeKind::Path { path, .. } = &mut half.kind { + *path = shapes::rectangle(Rect::new(75.0, 75.0, 125.0, 125.0)); + } + let want = bounds(&group(half)); + let got = bounds(&scaled); + assert!((got.x0 - want.x0).abs() < 0.5 && (got.x1 - want.x1).abs() < 0.5, "{got:?} against {want:?}"); + assert!((want.x0 - 65.0).abs() < 0.5, "{want:?}"); + // The clip group as the canvas evaluates it for its effect, and as an export bakes it. + let evaluated = crate::evaluate_container(None, &scaled).unwrap(); + let NodeKind::Group { children, clip: true } = &evaluated.kind else { panic!("not a clip group") }; + let b = clip_outline(&children[0]).unwrap().0.bounding_box(); + assert!((b.x0 - want.x0).abs() < 0.5 && (b.x1 - want.x1).abs() < 0.5, "evaluated: {b:?} against {want:?}"); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, scaled).unwrap(); + let b = clip_outline(&baked_clip(&d)).unwrap().0.bounding_box(); + assert!((b.x0 - want.x0).abs() < 0.5 && (b.x1 - want.x1).abs() < 0.5, "exported: {b:?} against {want:?}"); +} + +#[test] +fn nested_effects_apply_before_the_clip_groups() { + // The clip group grows its clipping path by 10 (Offset Path). Inside the clipping path a + // Transform moves a member 60 pt right: the region grows from the moved member. + let evaluated = |clip: Node| { + let mut g = clip_group(1, clip); + g.appearance.effects.push(fx("path.offsetPath", json!({"offset": 10}))); + let e = crate::evaluate_container(None, &g).unwrap(); + let NodeKind::Group { children, clip: true } = &e.kind else { panic!("not a clip group") }; + clip_outline(&children[0]).unwrap().0.bounding_box() + }; + let moved = || clipping_path(3, vec![fx("distort.transform", json!({"moveH": 60}))]); + let close = |a: Rect, b: Rect| (a.x0 - b.x0).abs() < 0.5 && (a.x1 - b.x1).abs() < 0.5 && (a.y0 - b.y0).abs() < 0.5; + let want = Rect::new(100.0, 40.0, 220.0, 160.0); + // A group holding the member. + let b = evaluated(Node::group(NodeId(2), vec![Arc::new(moved())])); + assert!(close(b, want), "group: {b:?}"); + // A compound path of the member, with a Transform of its own moving it 10 pt down. + let mut compound = Node::new(NodeId(2), NodeKind::Compound { children: vec![Arc::new(moved())], rule: vectorcraft_geom::FillRule::NonZero }); + compound.appearance.effects.push(fx("distort.transform", json!({"moveV": 10}))); + let b = evaluated(compound); + assert!(close(b, Rect::new(100.0, 50.0, 220.0, 170.0)), "compound: {b:?}"); +} + +#[test] +fn clipping_type_keeps_its_effect_where_other_type_stays_type() { + // Type moved 30 pt right by its Transform clips a red square, beside other type, in a group + // that a Transform moves 10 pt down: the export keeps the other type as type and outlines the + // clipping type, which still clips where the canvas clips. + use vectorcraft_doc::text::{CharStyle, TextObject}; + let text = |id: u64, effects: Vec| { + let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; + let mut t = Node::new(NodeId(id), NodeKind::Text(Box::new(TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", style)))); + t.appearance.effects = effects; + t + }; + let clipped = clip_group(2, text(3, vec![fx("distort.transform", json!({"moveH": 30}))])); + let mut outer = Node::group(NodeId(1), vec![Arc::new(clipped), Arc::new(text(5, vec![]))]); + outer.appearance.effects.push(fx("distort.transform", json!({"moveV": 10}))); + fn clip_of(n: &Node) -> Option { + match &n.kind { + NodeKind::Group { children, clip: true } => Some(clip_outline(children.first()?)?.0.bounding_box()), + _ => n.children()?.iter().find_map(|c| clip_of(c)), + } + } + if vectorcraft_text::CRAFT_FONTS.is_empty() { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + return; + } + let plain = clip_outline(&text(3, vec![])).unwrap().0.bounding_box(); + let canvas = clip_of(&crate::evaluate_container(None, &outer).unwrap()).unwrap(); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, outer).unwrap(); + let baked = bake_document(&d).unwrap(); + let mut exported = None; + for l in &baked.layers { + exported = exported.or_else(|| clip_of(l)); + } + let exported = exported.unwrap(); + assert!((canvas.x0 - plain.x0 - 30.0).abs() < 0.5 && (canvas.y0 - plain.y0 - 10.0).abs() < 0.5, "{canvas:?} from {plain:?}"); + assert!((exported.x0 - canvas.x0).abs() < 0.5 && (exported.y0 - canvas.y0).abs() < 0.5, "exported {exported:?}, canvas {canvas:?}"); + // The outlined clipping type keeps its black fill, which paints behind the clipped art. + let outlined = crate::reshape::clipping_text_outline(&text(3, vec![fx("distort.transform", json!({"moveH": 30}))])).unwrap(); + fn fills(n: &Node) -> usize { + n.appearance.items.iter().filter(|i| matches!(i, vectorcraft_doc::AppearanceItem::Fill(f) if f.paint != Paint::None)).count() + + n.children().into_iter().flatten().map(|c| fills(c)).sum::() + } + assert!(fills(&outlined) > 0, "{outlined:?}"); +} + +#[test] +fn a_painted_clipping_paths_gradient_follows_its_effect() { + // A clipping path filled with a gradient placed across it, moved 60 pt right by its Transform + // effect, in a clip group with an effect of its own: the gradient moves with the path. + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, Color::WHITE)]; + let geom = GradientGeom { start: (50.0, 100.0).into(), end: (150.0, 100.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) })); + let mut clip = clipping_path(2, vec![fx("distort.transform", json!({"moveH": 60}))]); + clip.appearance = Appearance { effects: clip.appearance.effects.clone(), ..Appearance::basic(placed, Paint::None, 0.0) }.into(); + let mut group = clip_group(1, clip); + group.appearance.effects.push(fx("path.offsetPath", json!({"offset": 0}))); + let evaluated = crate::evaluate_container(None, &group).unwrap(); + let NodeKind::Group { children, clip: true } = &evaluated.kind else { panic!("not a clip group") }; + let Paint::Gradient(g) = children[0].appearance.fill_paint() else { panic!("no gradient") }; + let start = g.geom.unwrap().start; + assert!((start.x - 110.0).abs() < 1e-6, "the gradient starts at the moved path's left edge: {start:?}"); +} + +#[test] +fn a_nested_clip_groups_hidden_clipping_path_keeps_its_effect() { + // A clip group used as a clipping path clips by its clipping path whether it is shown or not. + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + let mut hidden = clipping_path(4, vec![fx("distort.transform", json!({"moveH": -30}))]); + hidden.visible = false; + let outer = clip_group(1, clip_group(3, hidden)); + d.insert(Some(layer), 0, outer.clone()).unwrap(); + let NodeKind::Group { children, .. } = &outer.kind else { panic!("not a group") }; + let b = bounds(&children[0]); + assert!((b.x0 - 20.0).abs() < 1e-6 && (b.x1 - 120.0).abs() < 1e-6, "{b:?}"); + assert_eq!(clip_outline(&children[0]), clip_outline(&baked_clip(&d)), "the canvas clips by the outline the export writes"); +} + +#[test] +fn a_fills_geometry_effect_shapes_the_clip_as_an_export_does() { + // The clipping path's fill has a Transform effect moving it 60 pt right; the path has none. + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + let mut clip = clipping_path(2, vec![]); + clip.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 0.0)), Paint::None, 0.0).into(); + for item in &mut clip.appearance.items { + if let vectorcraft_doc::AppearanceItem::Fill(f) = item { + f.effects.push(fx("distort.transform", json!({"moveH": 60}))); + } + } + let group = clip_group(1, clip); + d.insert(Some(layer), 0, group.clone()).unwrap(); + let NodeKind::Group { children, .. } = &group.kind else { panic!("not a group") }; + let live = clip_outline(&children[0]).unwrap(); + let exported = clip_outline(&baked_clip(&d)).unwrap(); + assert_eq!(live.0.bounding_box(), exported.0.bounding_box(), "the canvas clips by the outline the export writes"); + assert!((live.0.bounding_box().x1 - 210.0).abs() < 1e-6, "the moved fill reaches 210: {:?}", live.0.bounding_box()); + // A path of a compound clipping path with a fill effect of its own stays part of the region. + let mut member = clipping_path(4, vec![]); + member.appearance = children[0].appearance.clone(); + let other = Node::path(NodeId(5), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default()); + let compound = + Node::new(NodeId(3), NodeKind::Compound { children: vec![Arc::new(member), Arc::new(other)], rule: vectorcraft_geom::FillRule::NonZero }); + assert_eq!(bounds(&compound), Rect::new(0.0, 0.0, 150.0, 150.0)); +} + +#[test] +fn a_fills_geometry_effect_shapes_the_clip_before_the_effects_around_it() { + // Offset Path 10 on the fill of an unpainted clipping path, in a group that its Transform + // scales to 50 %: the region is the grown square at half size, 60 pt across, as it is when the + // clipping path is the member of a group that clips by its members. + let clip_with = |fill: Paint| { + let mut clip = clipping_path(3, vec![]); + clip.appearance = Appearance::basic(fill, Paint::None, 0.0).into(); + for item in &mut clip.appearance.items { + if let vectorcraft_doc::AppearanceItem::Fill(f) = item { + f.effects.push(fx("path.offsetPath", json!({"offset": 10}))); + } + } + clip + }; + let scaled = |clip: Node| { + let mut outer = Node::group(NodeId(1), vec![Arc::new(clip_group(2, clip))]); + outer.appearance.effects.push(fx("distort.transform", json!({"scaleH": 50, "scaleV": 50}))); + outer + }; + fn first_clip(n: &Node) -> Option> { + match &n.kind { + NodeKind::Group { children, clip: true } => children.first().cloned(), + _ => n.children()?.iter().find_map(|c| first_clip(c)), + } + } + let clip_of = |n: &Node| clip_outline(&first_clip(n).unwrap()).unwrap().0.bounding_box(); + let close = |r: Rect| (r.width() - 60.0).abs() < 0.5 && (r.height() - 60.0).abs() < 0.5; + let nested = clip_of(&crate::evaluate_container(None, &scaled(Node::group(NodeId(4), vec![Arc::new(clip_with(Paint::None))]))).unwrap()); + assert!(close(nested), "nested: {nested:?}"); + let outer = scaled(clip_with(Paint::None)); + let canvas = clip_of(&crate::evaluate_container(None, &outer).unwrap()); + assert!(close(canvas), "canvas: {canvas:?}"); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, outer).unwrap(); + let baked = bake_document(&d).unwrap(); + let exported = baked.layers.iter().find_map(|l| first_clip(l)).map(|c| clip_outline(&c).unwrap().0.bounding_box()).unwrap(); + assert!(close(exported), "exported: {exported:?}"); + // A clipping path that paints its fill keeps it, a path with the fill's effect live. + let painted = first_clip(&crate::evaluate_container(None, &scaled(clip_with(Paint::solid(Color::BLACK)))).unwrap()).unwrap(); + assert!(painted.clip_paint().fill.is_some(), "{painted:?}"); +} + +#[test] +fn a_baked_clipping_path_gives_its_pieces_ids_of_their_own() { + // An unpainted clipping path whose fill and stroke have Transform effects, in a clip group + // without effects: the export bakes it into a piece for each, every object with its own id. + let mut d = Document::new(400.0, 400.0); + let (group, path) = (d.alloc_id(), d.alloc_id()); + let mut clip = clipping_path(path.0, vec![]); + clip.appearance = Appearance::basic(Paint::None, Paint::None, 1.0).into(); + for item in &mut clip.appearance.items { + match item { + vectorcraft_doc::AppearanceItem::Fill(f) => f.effects.push(fx("distort.transform", json!({"moveH": 60}))), + vectorcraft_doc::AppearanceItem::Stroke(s) => s.effects.push(fx("distort.transform", json!({"moveV": 60}))), + } + } + let layer = d.layers[0].id; + d.insert(Some(layer), 0, clip_group(group.0, clip)).unwrap(); + let baked = bake_document(&d).unwrap(); + let clip = baked_clip(&d); + assert_eq!(clip.children().map(|c| c.len()), Some(2), "{clip:?}"); + fn ids(n: &Node, out: &mut Vec) { + out.push(n.id); + for c in n.children().into_iter().flatten() { + ids(c, out); + } + } + let mut all = vec![]; + for l in &baked.layers { + ids(l, &mut all); + } + let unique: std::collections::HashSet<_> = all.iter().collect(); + assert_eq!(unique.len(), all.len(), "{all:?}"); +} + +#[test] +fn a_hidden_member_with_an_effect_adds_nothing() { + let shown = Node::path(NodeId(3), shapes::rectangle(Rect::new(50.0, 50.0, 100.0, 100.0)), Appearance::default()); + let mut hidden = clipping_path(4, vec![fx("distort.transform", json!({"moveH": 200}))]); + hidden.visible = false; + let group = Node::group(NodeId(2), vec![Arc::new(shown), Arc::new(hidden)]); + assert_eq!(bounds(&group), Rect::new(50.0, 50.0, 100.0, 100.0)); +} + +#[test] +fn raster_and_hidden_effects_leave_the_clip_as_it_is() { + let mut hidden = fx("distort.transform", json!({"moveH": 60})); + hidden.visible = false; + for effects in [vec![], vec![fx("blur.gaussian", json!({"radius": 10}))], vec![hidden]] { + assert_eq!(bounds(&clipping_path(1, effects)), Rect::new(50.0, 50.0, 150.0, 150.0)); + } +} diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index a3b2800fd3..4779afaa27 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -7358,6 +7358,367 @@ fn an_image_on_hidden_lines_does_not_come_in_beside_its_look() { assert_eq!(art[0].name.as_deref(), Some("Photo")); } +/// A 64 × 64 RGB image, every pixel of it different, 128 pt square at (20, 10) in `doc`. +fn speckled_photo(doc: &mut Document) -> Node { + let px = image::RgbImage::from_fn(64, 64, |x, y| image::Rgb([(x * 4) as u8, (y * 4) as u8, ((x * 7 + y * 13) % 256) as u8])); + image_node(doc, ImageBlob::png(png_of(px.into())), (64, 64), Affine::new([2.0, 0.0, 0.0, 2.0, 20.0, 10.0]), None) +} + +/// The sizes `%%BeginData:` gives the images' samples in the program written from `doc`. +fn sample_sizes(doc: &Document) -> Vec { + let program = export_ai_editing(doc, &opts()).unwrap().program; + String::from_utf8_lossy(&program).lines().filter_map(|l| l.strip_prefix("%%BeginData: ")?.trim().parse().ok()).collect() +} + +/// The samples of a 64 × 64 RGB image, as `%%BeginData:` counts them (`XI` and CR LF too). +const SPECKLED_WHOLE: usize = 64 * 64 * 3 + 4; + +/// The pieces of an image cut into a raster mesh warp (as Object › Expand leaves a Warp or an +/// envelope on an image) are written as the image whole once, then each as the part of it that its +/// triangle shows: the file holds a little more than the image's pixels rather than a copy for every +/// piece, and it reads back drawing as it did. +#[test] +fn the_pieces_of_a_warped_image_are_written_as_the_parts_they_show() { + // GUARDS: each of a warped image's 200 pieces was written with all of the image's samples. + let doc = photo_doc(|d| { + let photo = speckled_photo(d); + let NodeKind::Image(im) = &photo.kind else { panic!("not an image") }; + let bend = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 12.0 * ((p.x - 20.0) / 128.0 * std::f64::consts::PI).sin()); + vectorcraft_doc::live::mesh_warp_image(&photo, im, &bend, 10, false, false) + }); + let sizes = sample_sizes(&doc); + assert_eq!(sizes.len(), 200, "{sizes:?}"); + assert_eq!(sizes[0], SPECKLED_WHOLE, "the first piece holds the whole image"); + // Each piece holds its cell (two triangles share one) and 2 pixels more around it. + let total: usize = sizes.iter().sum(); + assert!(total < 10 * SPECKLED_WHOLE, "{total} bytes of samples for an image of {SPECKLED_WHOLE}"); + let back = read_back(&doc); + let st = vectorcraft_testkit::raster::perceptual_diff( + &vectorcraft_testkit::raster::render_artboard(&doc), + &vectorcraft_testkit::raster::render_artboard(&back), + 8.0, + ); + assert!((st.bad_pixels as f64) < 0.005 * st.pixels as f64, "{} of {} pixels differ", st.bad_pixels, st.pixels); +} + +/// The pieces of a warped image that Preserve Shape Using Transparency cuts with opacity masks that +/// clip are written as the parts their masks show, in a group without knockout, and the file reads +/// back drawing as it did. +#[test] +fn masked_pieces_of_a_warped_image_are_written_as_the_parts_they_show() { + // GUARDS: each masked piece of a warped image was written with all of the image's samples. + // GUARDS: the pieces' knockout group read back with each piece hiding the pieces below it. + let doc = photo_doc(|d| { + let photo = speckled_photo(d); + let NodeKind::Image(im) = &photo.kind else { panic!("not an image") }; + let bend = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 12.0 * ((p.x - 20.0) / 128.0 * std::f64::consts::PI).sin()); + vectorcraft_doc::live::mesh_warp_image(&photo, im, &bend, 4, false, true) + }); + assert_eq!(doc.layers[0].children().unwrap()[0].knockout, vectorcraft_doc::Knockout::On); + let sizes = sample_sizes(&doc); + assert_eq!(sizes.len(), 32, "{sizes:?}"); + assert_eq!(sizes[0], SPECKLED_WHOLE, "the first piece holds the whole image"); + let total: usize = sizes.iter().sum(); + assert!(total < 6 * SPECKLED_WHOLE, "{total} bytes of samples for an image of {SPECKLED_WHOLE}"); + let back = read_back(&doc); + let st = vectorcraft_testkit::raster::perceptual_diff( + &vectorcraft_testkit::raster::render_artboard(&doc), + &vectorcraft_testkit::raster::render_artboard(&back), + 8.0, + ); + assert!((st.bad_pixels as f64) < 0.005 * st.pixels as f64, "{} of {} pixels differ", st.bad_pixels, st.pixels); +} + +/// The masked pieces of a warped image whose pixels are half transparent read back drawing as they +/// did, except where neighboring pieces overlap: there the image's alpha draws twice. +#[test] +fn masked_pieces_of_a_translucent_warped_image_read_back_but_for_their_overlaps() { + // GUARDS: the pieces' knockout group read back with each piece hiding the pieces below it + // (8,942 of 20,000 pixels differed). + let doc = photo_doc(|d| { + let px = image::RgbaImage::from_fn(64, 64, |x, y| image::Rgba([(x * 4) as u8, (y * 4) as u8, ((x * 7 + y * 13) % 256) as u8, 128])); + let photo = image_node(d, ImageBlob::png(png_of(px.into())), (64, 64), Affine::new([2.0, 0.0, 0.0, 2.0, 20.0, 10.0]), None); + let NodeKind::Image(im) = &photo.kind else { panic!("not an image") }; + let bend = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 12.0 * ((p.x - 20.0) / 128.0 * std::f64::consts::PI).sin()); + vectorcraft_doc::live::mesh_warp_image(&photo, im, &bend, 4, false, true) + }); + let back = read_back(&doc); + let st = vectorcraft_testkit::raster::perceptual_diff( + &vectorcraft_testkit::raster::render_artboard(&doc), + &vectorcraft_testkit::raster::render_artboard(&back), + 8.0, + ); + assert!((st.bad_pixels as f64) < 0.05 * st.pixels as f64, "{} of {} pixels differ", st.bad_pixels, st.pixels); +} + +/// An image with a live Warp keeps its pixels whole once, and its drawn look's pieces are each the +/// part of them they show: it reads back as the image with its Warp live. +#[test] +fn an_image_with_a_live_warp_writes_its_pixels_whole_once() { + // GUARDS: a live Warp's drawn look wrote all of the image's samples for each of its pieces. + let doc = photo_doc(|d| { + let mut photo = speckled_photo(d); + photo.appearance = Appearance { effects: vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))], ..Appearance::default() }.into(); + photo + }); + let sizes = sample_sizes(&doc); + assert!(sizes.len() > 100, "{sizes:?}"); + assert_eq!(sizes.iter().filter(|s| **s == SPECKLED_WHOLE).count(), 1, "{sizes:?}"); + let total: usize = sizes.iter().sum(); + assert!(total < 10 * SPECKLED_WHOLE, "{total} bytes of samples for an image of {SPECKLED_WHOLE}"); + let back = read_back(&doc); + let art = back.layers[0].children().unwrap(); + assert_eq!(art.len(), 1, "{:#?}", lines(&back)); + assert!(matches!(art[0].kind, NodeKind::Image(_)), "{:?}", art[0].kind); + assert_eq!(art[0].appearance.effects.len(), 1, "{:?}", art[0].appearance); +} + +/// An image cut to a clipping path is written with all of its pixels when it is the first of them +/// written, so releasing the clipping path shows the whole image; a second copy cut to a small part +/// is written as that part, 2 pixels more on each side. +#[test] +fn a_clipped_image_written_first_keeps_all_of_its_pixels() { + let window = |d: &mut Document| { + let photo = speckled_photo(d); + let corner = clipping(d, shapes::rectangle(Rect::new(30.0, 20.0, 50.0, 40.0))); + group(d, vec![corner, photo], true) + }; + let doc = photo_doc(|d| { + let (a, b) = (window(d), window(d)); + group(d, vec![a, b], false) + }); + let sizes = sample_sizes(&doc); + assert_eq!(sizes.len(), 2, "{sizes:?}"); + assert_eq!(sizes[0], SPECKLED_WHOLE); + // The path shows 10 × 10 pixels. + assert_eq!(sizes[1], 14 * 14 * 3 + 4, "{sizes:?}"); + let back = read_back(&doc); + let st = vectorcraft_testkit::raster::perceptual_diff( + &vectorcraft_testkit::raster::render_artboard(&doc), + &vectorcraft_testkit::raster::render_artboard(&back), + 8.0, + ); + assert!((st.bad_pixels as f64) < 0.002 * st.pixels as f64, "{} of {} pixels differ", st.bad_pixels, st.pixels); +} + +/// The images in `n` and its members, each as its count of effects, its width and its height. +fn images_in(n: &Node, out: &mut Vec<(usize, u32, u32)>) { + if let NodeKind::Image(im) = &n.kind { + out.push((n.appearance.effects.len(), im.width, im.height)); + } + for c in n.children().into_iter().flatten() { + images_in(c, out); + } +} + +/// A copy of an image written whole already that its clipping path shows none of is written as one +/// pixel of it. +#[test] +fn a_copy_of_an_image_that_does_not_show_is_one_pixel() { + // GUARDS: a copy whose clipping path missed the image was written with all of its pixels. + let doc = photo_doc(|d| { + let photo = speckled_photo(d); + let corner = clipping(d, shapes::rectangle(Rect::new(30.0, 20.0, 50.0, 40.0))); + let first = group(d, vec![corner, photo], true); + let photo = speckled_photo(d); + let away = clipping(d, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 5.0))); + let second = group(d, vec![away, photo], true); + group(d, vec![first, second], false) + }); + assert_eq!(sample_sizes(&doc), [SPECKLED_WHOLE, 3 + 4]); +} + +/// A 200 × 100 document of: the speckled image when `first`; a square whose opacity mask's art is +/// the image then six squares; the image in a small clipping group. +fn mask_art_then_clipped_photo(first: bool) -> Document { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + if first { + let photo = speckled_photo(&mut doc); + doc.insert(Some(layer), usize::MAX, photo).unwrap(); + } + let mut masked = square(&mut doc, Rect::new(10.0, 10.0, 50.0, 50.0), Color::gray(0.5)); + // The image, then more objects than the clipping group after the mask holds. + let mut members = vec![speckled_photo(&mut doc)]; + for i in 0..6 { + let x = f64::from(i) * 10.0; + members.push(square(&mut doc, Rect::new(x, 0.0, x + 5.0, 5.0), Color::rgb(1.0, 1.0, 1.0))); + } + let art = group(&mut doc, members, false); + masked.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + doc.insert(Some(layer), usize::MAX, masked).unwrap(); + let photo = speckled_photo(&mut doc); + let corner = clipping(&mut doc, shapes::rectangle(Rect::new(30.0, 20.0, 50.0, 40.0))); + let clipped = group(&mut doc, vec![corner, photo], true); + doc.insert(Some(layer), usize::MAX, clipped).unwrap(); + doc +} + +/// For each object limit from none to all of `doc`'s objects: whether the mask is written, and the +/// sizes `%%BeginData:` gives the images' samples on `%_` lines (the mask's art) and on the others. +fn samples_within_each_limit(doc: &Document) -> Vec<(usize, bool, Vec, Vec)> { + let read = crate::import::objects_read(&export_ai_editing(doc, &opts()).unwrap().program).unwrap(); + (0..=read) + .map(|max| { + let text = String::from_utf8_lossy(&export_within(doc, &opts(), max).unwrap().program).replace("\r\n", "\n"); + let size = |l: &str| l.strip_prefix("%%BeginData: ")?.trim().parse::().ok(); + let hidden = text.lines().filter_map(|l| size(l.strip_prefix("%_")?)).collect(); + let shown = text.lines().filter_map(size).collect(); + (max, text.contains("(AI9_Mask)"), hidden, shown) + }) + .collect() +} + +/// An image whose whole copy was in art left out for the object limit (here an opacity mask's art) +/// is written whole again where it is next written, even cut to a small clipping path: the file +/// always holds all of its pixels once. +#[test] +fn an_image_whose_whole_copy_is_left_out_is_written_whole_again() { + // GUARDS: once the mask's art was left out, the clipped copy was written as the part it shows. + let mut left_out_with_copy = 0; + for (max, mask, hidden, shown) in samples_within_each_limit(&mask_art_then_clipped_photo(false)) { + if hidden.is_empty() && shown.is_empty() { + continue; + } + assert!(hidden.contains(&SPECKLED_WHOLE) || shown.contains(&SPECKLED_WHOLE), "room for {max}: {hidden:?} {shown:?}"); + left_out_with_copy += usize::from(!mask); + } + assert!(left_out_with_copy > 0, "no room left the mask out and kept the clipped copy"); +} + +/// An image written whole, then whole again in an opacity mask's art left out for the object limit, +/// is not written whole a third time: the first copy is kept, and the clipped copy after the mask is +/// the part it shows. +#[test] +fn an_image_written_whole_before_art_left_out_stays_written_whole() { + // GUARDS: the mask's copy took the first copy's place, so giving it back wrote the clipped copy whole. + let mut left_out = 0; + for (max, mask, _, shown) in samples_within_each_limit(&mask_art_then_clipped_photo(true)) { + assert!(shown.iter().filter(|s| **s == SPECKLED_WHOLE).count() <= 1, "room for {max}: {shown:?}"); + left_out += usize::from(!mask && shown.len() == 2); + } + assert!(left_out > 0, "no room left the mask out and kept both copies"); +} + +/// An image with a live Warp in a small clipping group keeps all of its pixels: its drawn look is +/// written first, and neither the look nor the image itself is cut to the clipping path, so it +/// reads back with its Warp live and every pixel it had. +#[test] +fn a_clipped_image_with_a_live_warp_keeps_all_of_its_pixels() { + // GUARDS: the image under a live Warp was cut to the clipping path around it. + let doc = photo_doc(|d| { + let mut photo = speckled_photo(d); + photo.appearance = Appearance { effects: vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))], ..Appearance::default() }.into(); + let corner = clipping(d, shapes::rectangle(Rect::new(30.0, 20.0, 60.0, 50.0))); + group(d, vec![corner, photo], true) + }); + let sizes = sample_sizes(&doc); + assert_eq!(sizes.iter().filter(|s| **s == SPECKLED_WHOLE).count(), 1, "the look's first piece: {sizes:?}"); + let total: usize = sizes.iter().sum(); + assert!(total < 10 * SPECKLED_WHOLE, "{total} bytes of samples for an image of {SPECKLED_WHOLE}"); + // The image itself, on `%_` lines. + let program = export_ai_editing(&doc, &opts()).unwrap().program; + let hidden: Vec = + String::from_utf8_lossy(&program).lines().filter_map(|l| l.strip_prefix("%_%%BeginData: ")?.trim().parse().ok()).collect(); + assert_eq!(hidden, [SPECKLED_WHOLE]); + let back = read_back(&doc); + let mut images = vec![]; + for n in back.layers[0].children().unwrap() { + images_in(n, &mut images); + } + assert_eq!(images, [(1, 64, 64)], "{:#?}", lines(&back)); +} + +/// An image cut by an opacity mask whose art has an effect that paints past its path keeps the +/// pixels the effect shows: a copy of an image written whole already is cut to all that its mask +/// paints. +#[test] +fn a_masked_image_keeps_the_pixels_its_mask_effects_show() { + // GUARDS: the copy was cut to the mask art's path, short of what its Offset Path paints. + let doc = photo_doc(|d| { + let photo = speckled_photo(d); + let corner = clipping(d, shapes::rectangle(Rect::new(20.0, 10.0, 24.0, 14.0))); + let first = group(d, vec![corner, photo], true); + let mut masked = speckled_photo(d); + let mut ap = Appearance::basic(solid(Color::WHITE), Paint::None, 0.0); + ap.effects = vec![fx("path.offsetPath", serde_json::json!({"offset": 30.0}))]; + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(70.0, 40.0, 80.0, 50.0)), ap); + masked.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(art), clip: true, invert: false, disabled: false, linked: true })); + group(d, vec![first, masked], false) + }); + let sizes = sample_sizes(&doc); + assert_eq!(sizes.len(), 2, "{sizes:?}"); + assert_eq!(sizes[0], SPECKLED_WHOLE); + // The offset art covers (40, 10) to (110, 80): 35 × 35 pixels of the image. + assert!(sizes[1] >= 35 * 35 * 3, "{sizes:?}"); +} + +/// The speckled image with an opacity mask that clips it to six small white squares in its corner. +fn photo_masked_to_its_corner(doc: &mut Document) -> Node { + let mut masked = speckled_photo(doc); + let squares = (0..6).map(|i| square(doc, Rect::new(20.0 + f64::from(i) * 5.0, 10.0, 23.0 + f64::from(i) * 5.0, 20.0), Color::WHITE)).collect(); + let art = group(doc, squares, false); + masked.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(art), clip: true, invert: false, disabled: false, linked: true })); + masked +} + +/// A copy of an image written whole already whose opacity mask is left out for the object limit is +/// written whole: without the mask, it shows all of its pixels. +#[test] +fn a_masked_copy_whose_mask_is_left_out_keeps_all_of_its_pixels() { + // GUARDS: the copy was cut to its mask, and the mask then left out, so it read back cropped. + let doc = photo_doc(|d| { + let photo = speckled_photo(d); + let masked = photo_masked_to_its_corner(d); + group(d, vec![photo, masked], false) + }); + let (mut left_out, mut kept) = (0, 0); + for (max, mask, _, shown) in samples_within_each_limit(&doc) { + if shown.len() < 2 { + continue; + } + if mask { + assert!(shown[1] < SPECKLED_WHOLE, "room for {max}: {shown:?}"); + kept += 1; + } else { + assert_eq!(shown, [SPECKLED_WHOLE, SPECKLED_WHOLE], "room for {max}"); + left_out += 1; + } + } + assert!(left_out > 0 && kept > 0, "{left_out} limits left the mask out, {kept} kept it"); +} + +/// A copy of an image written whole already whose opacity mask is nested too deep to be written is +/// written whole. +#[test] +fn a_masked_copy_nested_too_deep_for_its_mask_keeps_all_of_its_pixels() { + // GUARDS: the copy was cut to its mask, and the mask then left out for its depth. + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let photo = speckled_photo(&mut doc); + doc.insert(Some(layer), usize::MAX, photo).unwrap(); + // Isolated instances are written as their art, each a level deeper: the masked copy is at the limit. + let art = photo_masked_to_its_corner(&mut doc); + doc.symbols.push(Symbol { dynamic: false, name: "S0".into(), art: Arc::new(art) }); + for i in 1..writer::MAX_NEST { + let mut inner = instance(&mut doc, &format!("S{}", i - 1), Affine::IDENTITY); + inner.isolate = true; + doc.symbols.push(Symbol { dynamic: false, name: format!("S{i}"), art: Arc::new(inner) }); + } + let mut outer = instance(&mut doc, &format!("S{}", writer::MAX_NEST - 1), Affine::IDENTITY); + outer.isolate = true; + doc.insert(Some(layer), usize::MAX, outer).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::DEEP_MASKS), "{:?}", out.warnings); + // The symbols' definitions hold copies too: each copy written with its mask is the part the + // mask shows, and each copy whose mask is left out is whole. + let sizes = sample_sizes(&doc); + let whole = sizes.iter().filter(|s| **s == SPECKLED_WHOLE).count(); + let masks = String::from_utf8_lossy(&out.program).matches("(AI9_Mask)").count(); + assert_eq!(sizes.len() - whole, masks, "{sizes:?}"); + assert!(whole >= 2, "the first copy and a copy whose mask is left out: {sizes:?}"); +} + /// [`warped_photo`] linked to `/Users/someone/Job/art/photo.png`, in folder `art` next to the /// document's. fn linked_warped_photo(doc: &mut Document, effects: Vec) -> Node { diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 765856753c..64583d3949 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -281,6 +281,17 @@ pub(super) struct Writer<'a> { /// How many more envelopes may be written live: as many as the reader checks in one file /// ([`crate::import::ENVELOPE_CHECKS`]). envelopes_left: usize, + /// The image read last and its pixels ([`images::Samples::of`], `None` when they can't be + /// read): the pieces of a warped image, which follow one another, read them once. + last_samples: Option<(vectorcraft_doc::ImageBlob, Option)>, + /// The images written whole so far, by the address of their data (which each entry keeps), each + /// with the index of its samples in [`Self::payloads`]. + whole_images: HashMap>, usize)>, + /// The outlines of the clipping groups the art being written is in, in the document (empty in + /// a definition, an opacity mask's art and a brush's art, which are written apart from them). + shown: Vec, + /// The copy of an image that the last [`Self::image`] wrote as a window, when it did. + windowed: Option, } impl<'a> Writer<'a> { @@ -348,6 +359,10 @@ impl<'a> Writer<'a> { definition_objects: 0, definition_symbol_nodes: 0, max_objects: crate::import::MAX_EDITING_OBJECTS, + last_samples: None, + whole_images: HashMap::new(), + shown: vec![], + windowed: None, } } @@ -414,15 +429,21 @@ impl<'a> Writer<'a> { /// Art written since `c` as `lines` is not used: the objects and symbol art charged since are /// given back, except those of the definitions written since, and the images `lines` marks - /// are emptied (the others keep their indices). + /// are emptied (the others keep their indices). An image whose whole copy is among them is no + /// longer written whole ([`Self::whole_images`]). fn give_back(&mut self, c: Charges, lines: &str) { self.objects = c.objects + (self.definition_objects - c.kept_objects); self.symbol_nodes = c.symbol_nodes + (self.definition_symbol_nodes - c.kept_symbol_nodes); + let mut emptied = HashSet::new(); for index in lines.split(PAYLOAD).skip(1).step_by(2) { - if let Some(p) = index.parse::().ok().and_then(|i| self.payloads.get_mut(i)) { + if let Some((i, p)) = index.parse::().ok().and_then(|i| Some((i, self.payloads.get_mut(i)?))) { *p = Vec::new(); + emptied.insert(i); } } + if !emptied.is_empty() { + self.whole_images.retain(|_, (_, i)| !emptied.contains(i)); + } } /// Whether the art of a symbol, `art`, may be written: the objects of the symbol art written @@ -643,8 +664,17 @@ impl<'a> Writer<'a> { } self.effects(n); self.state(n.locked, !n.visible); - let xy = Xy::of(n); + let mut xy = Xy::of(n); + if masked_warp_pieces(n) { + // The reader gives the pieces no mark, and a knockout group's member then knocks out + // what it paints without its opacity mask: each piece would hide the pieces below it. + // Without knockout, a partly transparent image draws its alpha twice only where the + // pieces overlap. + xy.knockout = Knockout::Off; + } let start = self.body.len(); + // An image's copy written as the part that its opacity mask, among other regions, shows. + let mut mask_window = None; match &n.kind { NodeKind::Layer { .. } => {} NodeKind::Group { children, clip: true } => self.clip_group(children, xy), @@ -671,7 +701,25 @@ impl<'a> Writer<'a> { } NodeKind::Compound { rule, .. } => self.shape(n, &compound_path(n), *rule, xy), NodeKind::Text(_) => self.text(n, xy), - NodeKind::Image(im) => self.image(&self.doc.shown_image(n, im), xy), + NodeKind::Image(im) => { + // An opacity mask that clips shows the image within what its art paints only (its + // effects and its symbol instances' art included, as the renderer measures it). + let masked = n + .extras() + .mask + .as_ref() + .filter(|m| m.clip && !m.invert && !m.disabled && self.nest < MAX_NEST) + .and_then(|m| vectorcraft_render::painted_bounds(self.doc, &m.art)); + if let Some(b) = masked { + self.shown.push(b.to_path(0.1)); + } + self.windowed = None; + self.image(&self.doc.shown_image(n, im), xy); + if masked.is_some() { + self.shown.pop(); + mask_window = self.windowed.take(); + } + } NodeKind::SymbolInstance { symbol, xf } if self.live_instance_in_room(n, symbol, *xf) => self.symbol_instance(symbol, *xf, xy), NodeKind::SymbolInstance { symbol, xf } => { if !self.open_group("u") { @@ -737,10 +785,14 @@ impl<'a> Writer<'a> { // A group's dictionary goes between its end and its state block. let block = (self.block_end == self.body.len() && self.block_start >= start).then(|| self.body.split_off(self.block_start)); self.block_end = usize::MAX; - self.dictionary(n); + let mask_written = self.dictionary(n); if let Some(b) = block { self.body.push_str(&b); } + // Without its opacity mask, the image shows whole. + if let Some(w) = mask_window.filter(|_| !mask_written) { + self.write_whole(w); + } } // An envelope's plugin `XP` follows its distorted art, after the art's state block. if deform { @@ -773,25 +825,28 @@ impl<'a> Writer<'a> { /// is bound to variables ([`Self::bound_variables`]; without a name, it gets an XML id and an /// empty `AIArtName`), has text wrap options ([`Self::text_wrap`]), has an opacity mask /// (`AI9_Mask`, see [`Self::mask`]), or is a symbol's art (its symbol's entries first, see - /// [`Self::define_symbol`]). - fn dictionary(&mut self, n: &Node) { + /// [`Self::define_symbol`]). False when `n` has an opacity mask that is left out: nested too + /// deep, or its art doesn't fit. + fn dictionary(&mut self, n: &Node) -> bool { let name = n.name.as_deref().filter(|s| !s.is_empty()); let wrap = n.extras().wrap.filter(|_| self.drawn == 0 && !self.in_look); let placed = std::mem::take(&mut self.placed); let typed = std::mem::take(&mut self.typed); let bound = self.bound_variables(n, placed, typed); let mut mask = n.extras().mask.as_deref(); + let mut mask_written = true; // Masks nested too deep are left out (an empty mask would hide the object). if mask.is_some() && self.nest >= MAX_NEST { self.warn(DEEP_MASKS); mask = None; + mask_written = false; } let entries = match &self.root_entries { Some((id, _)) if *id == n.id => self.root_entries.take().map(|(_, e)| e), _ => None, }; if name.is_none() && mask.is_none() && bound.is_empty() && entries.is_none() && wrap.is_none() { - return; + return mask_written; } let start = self.body.len(); let has_id = name.is_some() || !bound.is_empty(); @@ -817,13 +872,15 @@ impl<'a> Writer<'a> { // A mask whose art doesn't fit is left out, and with nothing else in it the dictionary too. if let Some(m) = mask && !self.mask(m) - && !has_id - && !has_entries - && wrap.is_none() { - return self.body.truncate(start); + if !has_id && !has_entries && wrap.is_none() { + self.body.truncate(start); + return false; + } + mask_written = false; } self.body.push_str("%_;\n%_\n"); + mask_written } /// Text wrap options, as entries of their object's dictionary: the offset in art space's points @@ -866,6 +923,7 @@ impl<'a> Writer<'a> { fn mask(&mut self, m: &OpacityMask) -> bool { let outer = std::mem::take(&mut self.body); let (state, gs, clip) = (self.state, self.gs, self.clip.take()); + let shown_by = std::mem::take(&mut self.shown); let (charges, bound) = (self.charges(), self.bound.len()); (self.state, self.gs) = (Some((false, false)), Some(Xy::OPAQUE)); // The canvas draws the art's root even when it is hidden, and the reader puts a hidden object @@ -905,6 +963,7 @@ impl<'a> Writer<'a> { self.hidden -= 1; self.nest -= 1; let lines = std::mem::replace(&mut self.body, outer); + self.shown = shown_by; if self.left_out_since(charges) { // The art isn't written, nor are the variable bindings of its objects. self.give_back(charges, &lines); @@ -990,9 +1049,11 @@ impl<'a> Writer<'a> { } } self.clip_paint_part(first, &path, rule, &fills); + self.shown.push(path.to_bezpath()); for c in rest { self.node(c); } + self.shown.pop(); self.clip_paint_part(first, &path, rule, &strokes); self.state(first.locked, !first.visible); let start = self.body.len(); @@ -1667,6 +1728,16 @@ fn clip_path(n: &Node) -> Option<(PathData, FillRule)> { path.subpaths.iter().any(|sp| !sp.anchors.is_empty()).then_some((path, rule)) } +/// Is `n` a knockout group of the pieces of a mesh-warped image cut by opacity masks +/// ([`vectorcraft_doc::live::mesh_warp_image`] with Preserve Shape Using Transparency)? Each piece +/// knocks out its triangle ([`Node::knockout_outline`]); read from the data, a piece knocks out all +/// that its image paints instead. +fn masked_warp_pieces(n: &Node) -> bool { + n.knockout == Knockout::On + && matches!(&n.kind, NodeKind::Group { children, clip: false } + if !children.is_empty() && children.iter().all(|c| c.extras().warp_piece && c.extras().mask.is_some())) +} + /// `text`'s lines on `%_` lines, as a part of the data that readers of the printed form skip (an /// opacity mask's art, a pattern's tile) has them. An image's samples stay as they are after an /// `XI` without `%_`, and `%_%%EndData` ends them, as in the files users sent. Samples hidden diff --git a/crates/eps/src/ai_editing/writer/brushes.rs b/crates/eps/src/ai_editing/writer/brushes.rs index e8a7d10b15..029b37597f 100644 --- a/crates/eps/src/ai_editing/writer/brushes.rs +++ b/crates/eps/src/ai_editing/writer/brushes.rs @@ -422,7 +422,7 @@ impl Writer<'_> { (self.to_art, self.grad_space, self.canvas), self.state.take(), self.gs.replace(Xy::OPAQUE), - self.clip.take(), + (self.clip.take(), std::mem::take(&mut self.shown)), (self.block_start, self.block_end), // The art is the brush's own: not on `%_` lines, in a drawn look or in a symbol that // first uses the brush. @@ -459,7 +459,7 @@ impl Writer<'_> { (self.to_art, self.grad_space, self.canvas), self.state, self.gs, - self.clip, + (self.clip, self.shown), (self.block_start, self.block_end), (self.hidden, self.in_look, self.in_symbol), ) = saved; diff --git a/crates/eps/src/ai_editing/writer/images.rs b/crates/eps/src/ai_editing/writer/images.rs index 355f5c2709..1d5b9a8c4c 100644 --- a/crates/eps/src/ai_editing/writer/images.rs +++ b/crates/eps/src/ai_editing/writer/images.rs @@ -2,13 +2,14 @@ //! (see [`crate::ai_editing`] for the form). use std::fmt::Write; +use std::sync::Arc; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{FillLayer, ImageBlob, ImageObject, LinkInfo}; use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Shape}; use super::{Writer, Xy}; -use crate::ps; +use crate::{ai_ops, ps}; pub(in crate::ai_editing) const IMAGES: &str = "images that can't be decoded or have more than 33,554,432 pixels are written as their outlines filled mid gray in the editing data for other apps"; pub(in crate::ai_editing) const IMAGE_DEPTH: &str = "images of 16 bits a channel are written at 8 bits a channel in the editing data for other apps"; @@ -26,8 +27,23 @@ pub(in crate::ai_editing) const PAYLOAD: char = '\u{1}'; /// What starts the line of an image's samples: `XI` and its payload, then `%%EndData`. pub(super) const SAMPLES_LINE: &str = "XI\u{1}"; +/// An image's pixels as the data holds them, and whether it had 16 bits a channel ([`Samples::of`]). +pub(super) type Read = Arc<(Samples, bool)>; + +/// A copy of an image written as the part of it that its regions show ([`Writer::image`]). +pub(super) struct Windowed { + /// Where its raster is in the body. + at: std::ops::Range, + /// The index of its samples in [`Writer::payloads`]. + payload: usize, + /// The image's pixels. + samples: Read, + /// What places the image's whole pixel grid in the document. + xf: Affine, +} + /// An image's pixels as the data holds them. -struct Samples { +pub(super) struct Samples { /// `/DeviceGray`, `/DeviceRGB` or `/DeviceCMYK`. space: &'static str, /// The image type of `XI` (§8.1): 1 gray, 3 RGB, 4 CMYK. @@ -79,6 +95,19 @@ impl Samples { Some((Samples { space, kind, width, height, color, alpha }, deep)) } + /// The pixels of `window` (`[x0, y0, x1, y1]`, within the image) alone. + fn window(&self, window: [u32; 4]) -> Samples { + let [x0, y0, x1, y1] = window; + Samples { + space: self.space, + kind: self.kind, + width: x1 - x0, + height: y1 - y0, + color: ai_ops::window_samples(&self.color, self.width, usize::from(self.kind), window), + alpha: self.alpha.as_ref().map(|a| ai_ops::window_samples(a, self.width, 1, window)), + } + } + /// `XI`'s fourth flag as the files users sent have it: 0 gray, 1 RGB, 2 CMYK, 16 more with an /// alpha channel. fn reserved(&self) -> u8 { @@ -96,10 +125,13 @@ impl Writer<'_> { /// placed file with a preview ([`Self::placed_file`]), except in a part of the data written on /// `%_` lines (other than a styled object's copy on the layers, [`Self::styled_object`]) and in /// a symbol's art. One whose pixels can't be read is its outline filled mid gray. Where the art is - /// cut to a clipping path, the image is in a clipping group of that path. + /// cut to a clipping path, the image is in a clipping group of that path; a raster of pixels + /// written whole already is then the part of them that the path shows ([`ai_ops::image_window`]). pub(super) fn image(&mut self, im: &ImageObject, xy: Xy) { let doc = self.doc; - let Some((samples, deep)) = doc.image_blob(im).and_then(Samples::of) else { + let blob = doc.image_blob(im); + let read = blob.and_then(|b| self.samples(b)); + let Some((samples, deep)) = read.as_deref() else { let bounds = PathData::from_bezpath(&(im.xf * Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)).to_path(0.1))); self.warn(IMAGES); return self.fill_object(&bounds, FillRule::NonZero, &FillLayer::new(Paint::solid(Color::gray(0.5))), xy); @@ -113,7 +145,7 @@ impl Writer<'_> { if !self.take_objects(1 + clipping) { return; } - if deep { + if *deep { self.warn(IMAGE_DEPTH); } let cut = self.clip.take(); @@ -129,13 +161,41 @@ impl Writer<'_> { Some(link) if (self.hidden == 0 || self.styled_copy) && !self.in_symbol => { // Cut to a region, the placed file is in a clipping group. self.placed = cut.is_none(); - self.placed_file(link, &samples, (im.width.max(1), im.height.max(1)), im.xf); + self.placed_file(link, samples, (im.width.max(1), im.height.max(1)), im.xf); } other => { if other.is_some() { self.warn(LINKS_EMBEDDED); } - self.raster(&samples, im.xf * fit); + // An image written whole already: the part its region shows, placed where it is in + // the image (the pieces of a warped image each show a small part of the same one). + // An object's editable original (on `%_` lines) and an image in an opacity mask's art + // are written whole. + let data = blob.map(|b| &b.bytes); + let written = self.hidden == 0 && data.is_some_and(|d| self.whole_images.contains_key(&(Arc::as_ptr(d) as usize))); + let window = written + .then(|| { + let clips: Vec<_> = self.shown.iter().cloned().chain(cut.iter().map(PathData::to_bezpath)).collect(); + ai_ops::image_window(&clips, im.xf * fit, (samples.width, samples.height)) + }) + .flatten(); + match (window, &read) { + (Some(window), Some(read)) => { + let at = self.body.len(); + self.raster(&samples.window(window), im.xf * fit * Affine::translate((f64::from(window[0]), f64::from(window[1])))); + self.windowed = + Some(Windowed { at: at..self.body.len(), payload: self.payloads.len() - 1, samples: read.clone(), xf: im.xf * fit }); + } + _ => { + if let Some(d) = data { + // `raster` adds the samples as the next payload. A copy written whole on + // `%_` lines after an earlier whole copy keeps the earlier one's entry. + let next = self.payloads.len(); + self.whole_images.entry(Arc::as_ptr(d) as usize).or_insert_with(|| (d.clone(), next)); + } + self.raster(samples, im.xf * fit); + } + } } } if let Some(region) = &cut { @@ -232,6 +292,32 @@ impl Writer<'_> { ); } + /// The pixels of `blob` ([`Samples::of`]), read once for images that follow one another with + /// the same data. + fn samples(&mut self, blob: &ImageBlob) -> Option { + if let Some((last, read)) = &self.last_samples + && Arc::ptr_eq(&last.bytes, &blob.bytes) + && last.mime == blob.mime + { + return read.clone(); + } + let read = Samples::of(blob).map(Arc::new); + self.last_samples = Some((blob.clone(), read.clone())); + read + } + + /// The copy `w`, written as a window, written whole in its place, with fresh samples; its + /// window's samples are emptied. + pub(super) fn write_whole(&mut self, w: Windowed) { + let tail = self.body.split_off(w.at.end); + self.body.truncate(w.at.start); + if let Some(p) = self.payloads.get_mut(w.payload) { + *p = Vec::new(); + } + self.raster(&w.samples.0, w.xf); + self.body.push_str(&tail); + } + /// Keep `bytes` to go in the program where the mark this returns is. fn payload(&mut self, bytes: Vec) -> String { self.payloads.push(bytes); diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index f1b52dd91b..d0a90ce86a 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -481,7 +481,7 @@ impl Writer<'_> { (self.to_art, self.grad_space, self.canvas), self.state.take(), self.gs.replace(Xy::OPAQUE), - self.clip.take(), + (self.clip.take(), std::mem::take(&mut self.shown)), self.block_end, ); // `Bg` places a gradient in a pattern's tile as on the canvas, mirrored top to bottom: its @@ -505,7 +505,7 @@ impl Writer<'_> { self.hidden -= 1; self.nest -= 1; let art; - (art, (self.to_art, self.grad_space, self.canvas), self.state, self.gs, self.clip, self.block_end) = saved; + (art, (self.to_art, self.grad_space, self.canvas), self.state, self.gs, (self.clip, self.shown), self.block_end) = saved; let art = std::mem::replace(&mut self.body, art); self.defining.pop(); if self.left_out_since(before) { diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index 5a80e50a78..ab9ed6a482 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -750,6 +750,9 @@ impl Writer<'_> { let xy = Xy::of(orig); let was = self.in_look; self.in_look = true; + // The reader draws the look apart from the clipping groups around the object to compare it + // with the object's effects, and keeps the object itself: neither is cut to those groups. + let shown_by = std::mem::take(&mut self.shown); self.line("u"); let mut look = n.clone(); (look.name, look.opacity, look.blend, look.isolate, look.knockout, look.knockout_shape) = @@ -800,6 +803,7 @@ impl Writer<'_> { } self.dictionary(orig); self.hidden -= 1; + self.shown = shown_by; let part = std::mem::replace(&mut self.body, saved); hide(&part, &mut self.body); self.block_end = usize::MAX; diff --git a/crates/eps/src/ai_editing/writer/symbols.rs b/crates/eps/src/ai_editing/writer/symbols.rs index 86d7c4694d..d058c9b770 100644 --- a/crates/eps/src/ai_editing/writer/symbols.rs +++ b/crates/eps/src/ai_editing/writer/symbols.rs @@ -245,7 +245,7 @@ impl Writer<'_> { (self.to_art, self.grad_space, self.canvas, self.instance_space), self.state.replace((false, false)), self.gs.replace(Xy::OPAQUE), - self.clip.take(), + (self.clip.take(), std::mem::take(&mut self.shown)), (self.block_start, self.block_end), (std::mem::replace(&mut self.in_symbol, true), std::mem::take(&mut self.hidden)), self.root_entries.take(), @@ -302,7 +302,7 @@ impl Writer<'_> { (self.to_art, self.grad_space, self.canvas, self.instance_space), self.state, self.gs, - self.clip, + (self.clip, self.shown), (self.block_start, self.block_end), (self.in_symbol, self.hidden), self.root_entries, diff --git a/crates/eps/src/ai_ops.rs b/crates/eps/src/ai_ops.rs index 3b2bdd88d8..6b1374cd83 100644 --- a/crates/eps/src/ai_ops.rs +++ b/crates/eps/src/ai_ops.rs @@ -7,7 +7,7 @@ use std::fmt::Write; use vectorcraft_color::{Color, Gradient}; use vectorcraft_doc::{LAYER_COLORS, LayerColor, LineCap, LineJoin, StrokeLayer}; -use vectorcraft_geom::{Affine, Anchor, AnchorKind, PathData, Point, SubPath}; +use vectorcraft_geom::{Affine, Anchor, AnchorKind, PathData, Point, Shape, SubPath}; use crate::ps; @@ -131,6 +131,55 @@ pub(crate) fn average(g: &Gradient) -> Color { Color::rgb(unit(sum[0] / n), unit(sum[1] / n), unit(sum[2] / n)) } +/// The pixels an image's part keeps past the edge of the region that shows it, on each side: as +/// many as smoothing reads from beside a pixel, and one more. +const WINDOW_MARGIN: f64 = 2.0; + +/// The part of an image of `w` × `h` pixels, which `to_doc` places in the document, that regions +/// `clips` (each in the document, the image cut to all of them) show, with [`WINDOW_MARGIN`] pixels +/// more on each side: `[x0, y0, x1, y1]`, in pixels from the top left. One pixel of the image, the +/// nearest to where the regions are, when they show none of it. `None` when that part has half the +/// image's pixels or more, or when `to_doc` can't be undone; the whole image is written then. +pub(crate) fn image_window(clips: &[kurbo::BezPath], to_doc: Affine, (w, h): (u32, u32)) -> Option<[u32; 4]> { + if clips.is_empty() || w == 0 || h == 0 || !to_doc.is_finite() || to_doc.determinant().abs() < 1e-12 { + return None; + } + let to_pixels = to_doc.inverse(); + let (mut x0, mut y0, mut x1, mut y1) = (0.0, 0.0, f64::from(w), f64::from(h)); + for clip in clips { + let b = (to_pixels * clip.clone()).bounding_box(); + if !b.is_finite() { + return None; + } + x0 = f64::max(x0, b.x0.floor() - WINDOW_MARGIN); + y0 = f64::max(y0, b.y0.floor() - WINDOW_MARGIN); + x1 = f64::min(x1, b.x1.ceil() + WINDOW_MARGIN); + y1 = f64::min(y1, b.y1.ceil() + WINDOW_MARGIN); + } + if x0 >= x1 || y0 >= y1 { + let (x, y) = ((x0 as u32).min(w - 1), (y0 as u32).min(h - 1)); + return Some([x, y, x + 1, y + 1]); + } + if 2.0 * (x1 - x0) * (y1 - y0) >= f64::from(w) * f64::from(h) { + return None; + } + Some([x0 as u32, y0 as u32, x1 as u32, y1 as u32]) +} + +/// The rows `y0..y1` and columns `x0..x1` (`window`) of `samples`, `w` pixels a row of `channels` +/// bytes each, rows from the top. +pub(crate) fn window_samples(samples: &[u8], w: u32, channels: usize, [x0, y0, x1, y1]: [u32; 4]) -> Vec { + let row = w as usize * channels; + let (from, to) = (x0 as usize * channels, x1 as usize * channels); + let mut out = Vec::with_capacity((y1 - y0) as usize * (to - from)); + for y in y0 as usize..y1 as usize { + if let Some(part) = samples.get(y * row + from..y * row + to) { + out.extend_from_slice(part); + } + } + out +} + /// The stroke's width, cap, join, miter limit and dashes: `w J j M d`. pub(crate) fn line_style(s: &mut String, st: &StrokeLayer) { let cap = match st.cap { diff --git a/crates/eps/src/import/ai/brush.rs b/crates/eps/src/import/ai/brush.rs index 1180e42fa9..fd28e42e47 100644 --- a/crates/eps/src/import/ai/brush.rs +++ b/crates/eps/src/import/ai/brush.rs @@ -18,7 +18,8 @@ //! 1 pt draws it at its own size), the brush by name (`/GObjRef : (name) (plug-in) //! /PluginObject`), and the brush's settings for this path as entries: `Diameter`, `Roundness`, //! `Angle`, their `…Variation` either side and `…DependsOn` codes, and `AngleRelativeTo` (0 the -//! page). +//! page). Samples of the pen may follow a path's own lines on `%_` lines (`(hex) Xt n XD`). They +//! aren't read, so such a path keeps its drawn look when a setting varies with the pen. //! - A path with an Art brush is a stroke in its art style too: the filter //! `(Adobe ArtOnPath Brush Tool)`, its stroke's paint and weight (the weight scales the art across //! the path), the brush by name, and its settings for this path as entries: `Scaling`, diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 9d66bd2d17..5ef013ecb5 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -599,6 +599,10 @@ struct Reader<'a> { compound_groups: HashSet, /// Groups of a text object and its look (see [`Self::style_marker`]), which aren't a look. text_looks: HashSet, + /// Objects followed by samples of the pen (`(…) Xt n XD`), which aren't read: such an object + /// whose style has a Calligraphic brush that varies with the pen keeps its drawn look (see + /// [`Self::style_marker`]). + pen_samples: HashSet, /// The gradient mesh being read (`/Mesh X!` … `/End X!`), and whether it started on a `%_` line. mesh: Option<(super::mesh::MeshReader, bool)>, /// The art styles (`%AI9_BeginArtStyles`), by name. @@ -791,6 +795,7 @@ impl<'a> Reader<'a> { ruler_guides: HashMap::new(), compound_groups: HashSet::new(), text_looks: HashSet::new(), + pen_samples: HashSet::new(), group_ended: false, mesh_ended: false, layer_state: None, diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index f10682b592..6b963a74ad 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -89,6 +89,18 @@ impl<'a> Reader<'a> { } } + /// `(…) Xt n XD`: samples of the pen along the object before it. They aren't read, so the object + /// keeps its drawn look when its style has a Calligraphic brush with a setting that varies with + /// the pen ([`Self::style_marker`]). + fn pen_samples_op(&mut self) { + if !self.after_object || self.pen_samples.len() >= MAX_NODES { + return; + } + if let Some(n) = self.frames.last().and_then(|f| f.children.last()) { + self.pen_samples.insert(n.id); + } + } + /// `/identifier (string) XT` (version 7 specification §7): a tag on the object before it. The /// `AdobeURL` tag sets the object's URL, and every other tag is added to the object's data in /// order, up to [`MAX_TAGS`]. A tag with no object before it, with an empty identifier, or with @@ -418,6 +430,7 @@ impl<'a> Reader<'a> { self.end_envelope(); } } + "XD" => self.pen_samples_op(), _ if IGNORED.contains(&w) => {} // Before the art, the setup runs procedures of the printing format. _ if self.frames.is_empty() => {} @@ -559,9 +572,9 @@ impl<'a> Reader<'a> { return; } self.image_tail = false; - // An object's tags (`XT`, and a graph's `Go`) follow it one after another, and an image's - // follow the `XH` that ends its data. - if matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB" | "XT" | "XH" | "Go") { + // An object's tags (`XT`, and a graph's `Go`) and samples of the pen (`Xt`, `XD`) follow it + // one after another, and an image's tags follow the `XH` that ends its data. + if matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB" | "XT" | "XH" | "Go" | "Xt" | "XD") { return; } self.after_object = false; @@ -688,6 +701,8 @@ impl<'a> Reader<'a> { /// drawn look before it (all on `%_` lines inside another such object). Other markers end what /// follows an object. fn style_marker(&mut self, vals: &[V], hidden: bool) { + use crate::import::ai::brush::PathBrush; + use crate::import::ai::style::{ArtStyle, StyleItem}; let code = Self::nums(vals).last().copied().unwrap_or(0.0); let style = vals.iter().rev().find_map(|v| self.names.text(v)).unwrap_or_default(); let after = std::mem::take(&mut self.after_object); @@ -754,8 +769,20 @@ impl<'a> Reader<'a> { } else { look }; - // A style read here: the object, painted as it says. - let appearance = self.styles.get(&style).and_then(|st| { + // A style read here: the object, painted as it says. An object with samples of the pen + // keeps its drawn look when its style has a Calligraphic brush with a setting that varies + // with the pen: the samples aren't read, and the brush draws such a stroke at the pen's + // middle values. + let sampled = self.pen_samples.contains(&object.id); + let follows_pen = |st: &ArtStyle| { + st.items.iter().any(|i| match i { + StyleItem::Stroke { brush: Some((_, PathBrush::Calligraphic(c))), .. } => { + c.modes().iter().zip(c.variation).any(|(m, v)| m.follows_pen() && v != 0.0) + } + _ => false, + }) + }; + let appearance = self.styles.get(&style).filter(|st| !(sampled && follows_pen(st))).and_then(|st| { styled( object, look, diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 83a838a6cb..b6e908c2e1 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -386,9 +386,8 @@ pub(super) const IGNORED: &[&str] = &[ // Path resolution (`Ar`, §12): how finely curves print. "Ar", "Xs", + // The samples of the pen that `XD` notes on the object before them (`Reader::pen_samples`). "Xt", - // Five indexed curves of samples along a brushed path (`(…) Xt n XD`), on its hidden source path. - "XD", "Xi", "XI", "LB2", diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 891c058bb6..7d570fca65 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -1445,13 +1445,38 @@ fn offset_stroke(f: &Filter) -> Result, ()> { stroke_item(f, if inside { StrokeAlign::Inside } else { StrokeAlign::Outside }) } +/// The entries a Calligraphic brush's filter may hold, with the settings [`calligraphic_stroke`] +/// reads. Any other entry keeps the drawn look. +const CALLIGRAPHIC_ENTRIES: [&str; 14] = [ + "StrokeStyle", + "DisplayString", + "Brush", + "Diameter", + "DiameterVariation", + "DiameterDependsOn", + "Roundness", + "RoundnessVariation", + "RoundnessDependsOn", + "Angle", + "AngleVariation", + "AngleDependsOn", + "AngleRelativeTo", + "Adobe Effect Expand Before Version", // brand-ok: the entry's name in the files +]; + /// A Calligraphic brush's filter `f` as the stroke it paints: `None` when it isn't a stroke in a -/// colour, names no Calligraphic brush, or its settings aren't all there as numbers. An angle -/// relative to the path isn't read. +/// color, names no Calligraphic brush, holds an entry or a width that isn't read, or its settings +/// aren't all there as numbers. An angle relative to the path isn't read. fn calligraphic_stroke(f: &Filter) -> Option { let real = |key: &str| entry(f, key, Kind::Real).ok().flatten(); let code = |key: &str| entry(f, key, Kind::Int).ok().flatten(); - if !f.paints.0 || f.gradient || f.pattern.is_some() || f.repeated { + if !f.paints.0 + || f.gradient + || f.pattern.is_some() + || f.repeated + || !f.arrays.is_empty() + || !f.kinds.keys().all(|k| CALLIGRAPHIC_ENTRIES.contains(&k.as_str())) + { return None; } if entry(f, "AngleRelativeTo", Kind::Int).ok()?.unwrap_or(0.0) != 0.0 { diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 7a821bf184..d160248a2d 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -10304,20 +10304,86 @@ fn a_path_with_a_calligraphic_brush_comes_in_brushed() { let pieces = vectorcraft_brush::stroke_pieces(&r.doc, &lib[0], &a[0].path_data().unwrap().to_bezpath(), st); let height = pieces.iter().filter_map(|n| n.geometric_bounds()).map(|b| b.height()).fold(0.0, f64::max); assert!(height > 40.0 && height < 46.0, "the line's rise and about half the nib's 3 pt: {height}"); + // A filter with the version entry an effect's filter may carry still comes in brushed. + let versioned = style.replace("(Brush) ,\n", "(Brush) ,\n15 /Int (Adobe Effect Expand Before Version) ,\n"); + let a = art(&read_ok(&format!("{def}{versioned}{}", layer(&brushed_path()))).doc); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "{a:#?}"); +} + +/// Samples of the pen along a path (`(…) Xt n XD`), as they follow its own lines. +const PEN_SAMPLES: &str = "%_(00FE64FE) Xt 0 XD\n%_(00006400) Xt 1 XD\n%_(00986498) Xt 2 XD\n%_(009E649E) Xt 3 XD\n%_(007F647F) Xt 4 XD\n"; + +/// A path with a Calligraphic brush that varies with the pen, followed by samples of the pen, comes +/// in as its drawn look alone, with the note that says so: the samples aren't read, and the brush +/// would draw it at the pen's middle values. Its own path doesn't come in beside the look. A brush +/// that doesn't follow the pen draws such a path the same without the samples, so it comes in +/// brushed. +#[test] +fn a_brushed_path_with_samples_of_the_pen_keeps_its_drawn_look() { + // GUARDS: the path came in beside its look, unpainted, and the note was missing. + let def = plugin_object(CALLIGRAPHIC, "Six D", SIX_D); + let style = brushed_style("Six D", 0.5, SIX_D_SETTINGS, [2, 5, 6], 0); + let path = brushed_path().replace("%_n\n", &format!("%_n\n{PEN_SAMPLES}")); + let r = read_ok(&format!("{def}{style}{}", layer(&path))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Group { .. }), "the drawn look: {a:#?}"); + assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); + // The samples belong to the path before them: a path after them is read as it was. + let after = format!("{path}{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0)); + let a = art(&read_ok(&format!("{def}{style}{}", layer(&after))).doc); + assert_eq!(a.len(), 2, "{a:#?}"); + assert!(matches!(a[1].kind, NodeKind::Path { .. }) && a[1].appearance.fill().is_some(), "{a:#?}"); + // Samples with no object before them in the layer mark nothing: the path after them is read. + let first = format!("{PEN_SAMPLES}{}", brushed_path()); + let a = art(&read_ok(&format!("{def}{style}{}", layer(&first))).doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the path, brushed: {a:#?}"); + // A brush whose modes are Fixed and Random, one whose pen modes vary by 0, and an Art brush + // don't follow the pen: the path comes in brushed. + let fixed_def = plugin_object(CALLIGRAPHIC, "Six D", &SIX_D.replace(" 2 5 6 0", " 0 1 0 0")); + let fixed = brushed_style("Six D", 0.5, SIX_D_SETTINGS, [0, 1, 0], 0); + let even_def = plugin_object(CALLIGRAPHIC, "Six D", "1 6 6 40 40 30 30 2 5 6 0"); + let even = brushed_style("Six D", 0.5, [(6.0, 0.0), (40.0, 0.0), (30.0, 0.0)], [2, 5, 6], 0); + for (why, def, style) in [("Fixed and Random", fixed_def, fixed), ("no variation", even_def, even)] { + let r = read_ok(&format!("{def}{style}{}", layer(&path))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{why}: {a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "{why}: the path, brushed: {a:#?}"); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{why}: {:?}", r.warnings); + } + let ribbon = format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", RIBBON)); + let a = art(&read_ok(&format!("{ribbon}{}{}", art_style("Ribbon", 2.0, &STRETCHED, ""), layer(&path))).doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert_eq!(a[0].appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Ribbon"), "{a:#?}"); + // A style with no brush doesn't draw the samples: the path takes it as it would without them. + let square = styled_square("Blue").replace("f\n1 (Blue) XW", &format!("f\n{PEN_SAMPLES}1 (Blue) XW")); + let r = read_ok(&format!("{}{}", styles_and_list(&[fill_style("Blue", "0 0 0 0 0 0 1 Xa\n")], &["Blue"]), layer(&square))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the path with its style: {a:#?}"); + assert_eq!(a[0].extras().graphic_style, Some(1), "linked to its style"); } /// A brushed path keeps its drawn look when its stroke can't be read as the brush: the object's /// own settings differ from the brush's (Options of Selected Object), its angle follows the path, -/// or the file defines no brush of its name. +/// its filter holds an entry or a variable width that isn't read, or the file defines no brush of +/// its name. #[test] fn a_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() { let def = plugin_object(CALLIGRAPHIC, "Six D", SIX_D); let mut own = SIX_D_SETTINGS; own[0] = (9.0, 2.0); + let plain = brushed_style("Six D", 1.0, SIX_D_SETTINGS, [2, 5, 6], 0); + let with = |more: &str| plain.replace("(Brush) ,\n", &format!("(Brush) ,\n{more}")); + let widths = "/Dictionary : /NotRecorded ,\n/Array :\n0 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n\ + /Array :\n1 /Real ,\n0.1 /Real ,\n; (BSCenteredWidthsArray) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n"; let cases = [ ("its own settings", def.clone(), brushed_style("Six D", 1.0, own, [2, 5, 6], 0)), ("its own modes", def.clone(), brushed_style("Six D", 1.0, SIX_D_SETTINGS, [2, 5, 4], 0)), ("an angle on the path", def.clone(), brushed_style("Six D", 1.0, SIX_D_SETTINGS, [2, 5, 6], 1)), + ("an entry that isn't read", def.clone(), with("1 /Real (Unread) ,\n")), + ("a variable width", def.clone(), with(widths)), ("no such brush", def, brushed_style("Other", 1.0, SIX_D_SETTINGS, [2, 5, 6], 0)), ]; for (why, def, style) in cases { diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index 4436c3a09f..663e21bec6 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -658,6 +658,47 @@ fn images_are_written_in_the_form_the_specification_shows() { assert!(rgb.iter().zip([247.0, 148.0, 29.0]).all(|(c, want)| (c * 255.0 - want).abs() < 0.1), "{rgb:?}"); } +/// The pieces of an image cut into a raster mesh warp (as Object › Expand leaves a Warp on an +/// image) are written as the image whole once, then each as the part of it that its triangle shows, +/// and the file draws as the document does ([`write`] runs it). +#[test] +fn the_pieces_of_a_warped_image_are_written_as_the_parts_they_show() { + // GUARDS: each of a warped image's 200 pieces was written with all of the image's samples. + let mut doc = Document::new(200.0, 160.0); + let mut png = vec![]; + image::RgbaImage::from_fn(64, 64, |x, y| image::Rgba([(x * 4) as u8, (y * 4) as u8, ((x * 7 + y * 13) % 256) as u8, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let blob = ImageBlob::new("image/png", png); + let key = blob.content_key(); + doc.images.insert(key.clone(), blob); + let im = ImageObject { + key, + width: 64, + height: 64, + xf: Affine::new([2.0, 0.0, 0.0, 2.0, 20.0, 10.0]), + link: None, + placement: Default::default(), + pixels: None, + }; + let photo = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im.clone()))); + let bend = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 12.0 * ((p.x - 20.0) / 128.0 * std::f64::consts::PI).sin()); + let warped = vectorcraft_doc::live::mesh_warp_image(&photo, &im, &bend, 10, false, false); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, warped).unwrap(); + let text = String::from_utf8(write(&doc).bytes).unwrap(); + let images = rasters(&text); + assert_eq!(images.len(), 200); + let digits = |lines: &Vec<&str>| { + lines.iter().filter_map(|l| l.strip_prefix('%')).filter(|l| l.bytes().all(|b| b.is_ascii_hexdigit())).map(str::len).sum::() + }; + let whole = 64 * 64 * 3 * 2; + assert_eq!(digits(&images[0]), whole, "the first piece holds the whole image"); + // Each piece holds its cell (two triangles share one) and 2 pixels more around it. + let total: usize = images.iter().map(digits).sum(); + assert!(total < 10 * whole, "{total} digits of samples for an image of {whole}"); +} + /// An image whose data is empty is left out with the note for empty or too large images, and an /// image whose data can't be decoded is left out with the decode note. #[test] diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index eda25a6322..0b41c3defa 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -45,6 +45,13 @@ pub(super) struct Writer<'a> { roman: bool, /// Inside a Kumi block `1 TY` began (Tate-chu-yoko in horizontal type). kumi: bool, + /// The outlines of the clipping groups the art being written is in, in the document. + clips: Vec, + /// The image decoded last (`None` when it can't be): the pieces of a warped image, which follow + /// one another, decode it once. + last_image: Option<(ImageBlob, Option>)>, + /// The images written whole so far, by the address of their data (which each entry keeps). + whole_images: std::collections::HashMap>>, } impl<'a> Writer<'a> { @@ -68,6 +75,9 @@ impl<'a> Writer<'a> { fonts: vec![], roman: false, kumi: false, + clips: vec![], + last_image: None, + whole_images: std::collections::HashMap::new(), } } @@ -363,12 +373,14 @@ impl<'a> Writer<'a> { if several { self.line("*U"); } + self.clips.push(outline); if let Some(fill) = &paint.fill { self.node(fill, locked); } for c in rest { self.node(c, locked); } + self.clips.pop(); self.lock(locked); self.line("Q"); if let Some(stroke) = &paint.stroke { @@ -1037,7 +1049,8 @@ impl<'a> Writer<'a> { /// whose pixels are all black or white is written as an image mask (ImageMask 1, §8.1): its /// color as the fill, then 1-bit samples, a 0 bit for each black pixel, which takes the color, /// and a 1 bit for each white pixel, which is clear. Another colorized image is written with - /// its color in its pixels. + /// its color in its pixels. In a clipping group, an image whose pixels were written whole + /// already is written as the part of them the group shows ([`ai_ops::image_window`]). fn image(&mut self, blob: Option<&ImageBlob>, width: u32, height: u32, xf: Affine, colorize: Option<&Paint>) { let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); let m = self.to_art * xf * flip; @@ -1048,7 +1061,7 @@ impl<'a> Writer<'a> { self.warn(LARGE_IMAGE); return; } - let Some(img) = blob.and_then(|b| image::load_from_memory(&b.bytes).ok()) else { + let Some((blob, img)) = blob.and_then(|b| Some((b, self.decoded(b)?))) else { self.warn(UNDECODED); return; }; @@ -1059,14 +1072,31 @@ impl<'a> Writer<'a> { } // The object places the image's own pixel grid (its width × height) wherever the file's is. let fit = Affine::scale_non_uniform(f64::from(width.max(1)) / f64::from(w), f64::from(height.max(1)) / f64::from(h)); - let m = self.to_art * xf * fit * flip; + let coverage = match colorize { + Some(paint) => { + let Some((_, _, coverage)) = ImageBlob::coverage(blob).filter(|(cw, ch, _)| (*cw, *ch) == (w, h)) else { + self.warn(UNDECODED); + return; + }; + Some((paint, coverage)) + } + None => None, + }; + // An image written whole already, cut to a clipping group: the part the group shows, placed + // where it is in the image (the pieces of a warped image each show a small part of the same one). + let key = Arc::as_ptr(&blob.bytes) as usize; + let window = if self.whole_images.contains_key(&key) { ai_ops::image_window(&self.clips, xf * fit, (w, h)) } else { None }; + if window.is_none() { + self.whole_images.insert(key, blob.bytes.clone()); + } + let window = window.unwrap_or([0, 0, w, h]); + let [x0, y0, x1, y1] = window; + let m = self.to_art * xf * fit * Affine::translate((f64::from(x0), f64::from(y0))) * flip; + let (w, h) = (x1 - x0, y1 - y0); // Over white. let over = |c: u8, a: u8| ((u32::from(c) * u32::from(a) + 255 * (255 - u32::from(a)) + 127) / 255) as u8; - if let Some(paint) = colorize { - let Some((_, _, coverage)) = blob.and_then(ImageBlob::coverage).filter(|(cw, ch, _)| (*cw, *ch) == (w, h)) else { - self.warn(UNDECODED); - return; - }; + if let Some((paint, coverage)) = coverage { + let coverage = ai_ops::window_samples(&coverage, img.width(), 1, window); if coverage.iter().all(|&v| v == 0 || v == 255) { let stride = (w as usize).div_ceil(8); let mut bits = vec![0xFF; stride.saturating_mul(h as usize)]; @@ -1086,7 +1116,7 @@ impl<'a> Writer<'a> { self.raster(m, &format!("0 0 {w} {h} {w} {h} 8 3 0 0 0 0"), &samples); return; } - let rgba = img.to_rgba8(); + let rgba = img.crop_imm(x0, y0, w, h).to_rgba8(); let mut samples = Vec::with_capacity(rgba.len() / 4 * 3); let mut see_through = false; for px in rgba.pixels() { @@ -1102,6 +1132,19 @@ impl<'a> Writer<'a> { self.raster(m, &format!("0 0 {w} {h} {w} {h} 8 3 0 0 0 0"), &samples); } + /// The pixels of `blob`, decoded once for images that follow one another with the same data. + fn decoded(&mut self, blob: &ImageBlob) -> Option> { + if let Some((last, img)) = &self.last_image + && Arc::ptr_eq(&last.bytes, &blob.bytes) + && last.mime == blob.mime + { + return img.clone(); + } + let img = image::load_from_memory(&blob.bytes).ok().map(Arc::new); + self.last_image = Some((blob.clone(), img.clone())); + img + } + /// An image's lines (§8.4): `%AI5_File:`, `%AI5_BeginRaster`, matrix `m` as `[ a b c d tx ty ]` /// and the numbers `operands` with `XI`, `samples` in hex on comment lines of [`HEX_LINE`] /// digits, `%AI5_EndRaster`, then `F`. diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index a6bc021fdc..c32d2d6c6f 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -1264,7 +1264,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.smoothAuto", "Auto-Smooth", "", - "{} the open Smooth slider (Object › Path › Smooth and the canvas context menu's Smooth… open it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open", + "{} the open Smooth slider (Object › Path › Smooth, the canvas context menu's Smooth… and the Contextual Task Bar's Smooth open it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open", ), ( "ui.simplifyMoreOptions", diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index ce76713e74..65ce8a91fd 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -623,7 +623,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool, brush: b let art = effects::evaluate_container(Some(symbols.document()), n); let art = art.as_ref().unwrap_or(n); let b = if art.clips() { - art.visual_bounds_with(symbols) + art.visual_bounds_with(symbols).map(|b| with_clip_region(symbols, art, b)) } else { art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, member(c))) }?; @@ -671,7 +671,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool, brush: b let b = n.visual_bounds_with(symbols)?; let b = match &n.kind { NodeKind::Text(t) => vectorcraft_geom::union_opt(Some(b), glyphs_box(t, n.appearance.outset())).unwrap_or(b), - _ => b, + _ => with_clip_region(symbols, n, b), }; let a = live_content_reach(symbols, n); Some(b.inflate(a, a)) @@ -679,6 +679,24 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool, brush: b } } +/// `b`, the visual bounds of `n`, grown to the region a clip group's or clipping layer's clipping +/// path clips to when geometry effects in it move or grow that region ([`effects::clip_outline`]) +/// past its plain shape, and to where its stroke paints along it. Other objects keep `b`. +fn with_clip_region(symbols: &SymbolBoxes, n: &Node, b: Rect) -> Rect { + match &n.kind { + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => match children.first() { + Some(c) if effects::needs_bake(c) => { + let s = c.appearance.outset(); + let region = effects::clip_outline(c).map(|(bp, _)| bp.bounding_box().inflate(s, s)); + let painted = if has_fx(c) { visual_bounds(symbols, c) } else { None }; + vectorcraft_geom::union_opt(vectorcraft_geom::union_opt(Some(b), region), painted).unwrap_or(b) + } + _ => b, + }, + _ => b, + } +} + /// The bounds of the art a 3D effect of `n` draws (Revolve, Extrude & Bevel, Inflate, Rotate), /// built now, without `n`'s own raster effects ([`three_d_art_bounds`]): `None` when it draws no /// such art, `Some(None)` when that art paints nothing. The renderer measures the art it keeps diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index f649453663..15bd354496 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -122,7 +122,8 @@ impl Renderer { /// When a plug-in has been installed or removed since the last check /// ([`effects::plugin_revision`]), drop what the caches keep from plug-in effects: the effect /// geometry of paths with a plug-in effect, the cull bounds of objects that have one or hold one, - /// of symbol instances and of the objects that hold them, and the art drawn for the effects of + /// of symbol instances and of the objects that hold them, the regions of clipping paths that + /// have one or hold one, and the art drawn for the effects of /// objects that have a plug-in effect or hold one. Rasters of raster effects and evaluations of live objects hold /// the revision in their keys when what they draw has a plug-in effect (`fx::Inputs`, /// `live::resources_key`). @@ -148,6 +149,8 @@ impl Renderer { self.geom.retain(|_, e| !holds_plugin_effect(&e.node) && !holds_instance(&e.node)); // The art of a group holds what its members' plug-in effects draw. self.fx_arts.retain(|(node, ..)| !holds_plugin_effect(node)); + // A clipping path's plug-in effects shape the region it clips to. + self.clips.retain(|(node, _)| !holds_plugin_effect(node)); } } diff --git a/crates/render/src/tests/clip.rs b/crates/render/src/tests/clip.rs index 7b04c02e2d..205c2a2583 100644 --- a/crates/render/src/tests/clip.rs +++ b/crates/render/src/tests/clip.rs @@ -95,6 +95,79 @@ fn nothing_to_clip_by_hides_the_art() { assert_eq!(r.pixel(50, 50), CLEAR); } +#[test] +fn the_clipping_paths_geometry_effects_shape_the_clip() { + // A Transform effect moving the left half 50 pt right: the right half shows instead. + let mut d = Document::new(100.0, 100.0); + let mut clip = Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 50.0, 100.0)), Appearance::basic(Paint::None, Paint::None, 0.0)); + clip.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 50}), visible: true }); + let r = clipped(d, Arc::new(clip)); + assert_eq!(r.pixel(25, 50), CLEAR, "the clipping path's own place"); + assert_eq!(r.pixel(75, 50), BLACK, "where its effect moves it"); +} + +#[test] +fn a_view_of_only_the_moved_clip_draws_the_clipped_art() { + // The clipping path covers x 0 to 50 and its Transform effect moves it to x 50 to 100; the + // view shows x 60 to 100, past the clipping path's own shape. + let mut d = Document::new(100.0, 100.0); + let mut clip = Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 50.0, 100.0)), Appearance::basic(Paint::None, Paint::None, 0.0)); + clip.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 50}), visible: true }); + let art = Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let members = vec![Arc::new(clip), Arc::new(art)]; + let g = Node::new(d.alloc_id(), NodeKind::Group { children: members.clone(), clip: true }); + // The same in a sublayer that clips by its first object. + let mut sublayer = (*d.layers[0]).clone(); + sublayer.id = d.alloc_id(); + if let NodeKind::Layer { children, clip, .. } = &mut sublayer.kind { + (*children, *clip) = (members, true); + } + for clipping in [g, sublayer] { + let mut d = d.clone(); + let l = d.layers[0].id; + d.insert(Some(l), 0, clipping).unwrap(); + let r = Renderer::new().render(&d, 40, 100, Affine::translate((-60.0, 0.0)), &RenderOptions::default()); + assert_eq!(r.pixel(20, 50), BLACK, "x 80, inside the moved clip"); + } +} + +#[test] +fn a_view_of_only_the_moved_clip_draws_a_clip_group_with_its_own_appearance() { + // As above, with the Transform effect on the clipping path itself or on its fill, and the clip + // group with an effect of its own: a Drop Shadow, or a Transform that moves nothing. + let moved = || vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 50}), visible: true }; + let shadow = vectorcraft_doc::Effect { + id: "stylize.dropShadow".into(), + params: serde_json::json!({"x": 0, "y": 6, "blur": 0, "opacity": 100}), + visible: true, + }; + let still = vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveV": 0}), visible: true }; + for on_fill in [false, true] { + for own in [shadow.clone(), still.clone()] { + let mut d = Document::new(100.0, 100.0); + let mut clip = Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 50.0, 100.0)), Appearance::basic(Paint::None, Paint::None, 0.0)); + match clip.appearance.items.iter_mut().find_map(|i| match i { + vectorcraft_doc::AppearanceItem::Fill(f) if on_fill => Some(f), + _ => None, + }) { + Some(f) => f.effects.push(moved()), + None => clip.appearance.effects.push(moved()), + } + let art = + Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let mut g = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true }); + g.appearance.effects.push(own.clone()); + let l = d.layers[0].id; + d.insert(Some(l), 0, g).unwrap(); + let what = format!("effect on the fill: {on_fill}, group effect {}", own.id); + let whole = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()); + assert_eq!(whole.pixel(80, 50), BLACK, "the whole canvas ({what})"); + let r = Renderer::new().render(&d, 40, 100, Affine::translate((-60.0, 0.0)), &RenderOptions::default()); + assert_eq!(r.pixel(20, 50), BLACK, "x 80, inside the moved clip ({what})"); + } + } +} + #[test] fn clip_regions_are_cached_per_clipping_path() { let mut d = Document::new(100.0, 100.0); diff --git a/crates/render/src/tests/container.rs b/crates/render/src/tests/container.rs index 89126cf2a2..dfc8dca91c 100644 --- a/crates/render/src/tests/container.rs +++ b/crates/render/src/tests/container.rs @@ -217,9 +217,9 @@ fn type_in_a_group_keeps_its_transparency_and_visibility_under_the_groups_geomet #[test] fn type_in_a_clipping_path_clips_alike_under_the_groups_geometry_effects() { - // The red square is clipped by type with a Transform of its own, which the clip leaves out, - // then by a group of that type and a small square, with the type shown and hidden. Offset - // Path 0 on the clip group leaves every path as it is. + // The red square is clipped by type with a Transform of its own, then by a group of that type + // and a small square, with the type shown and hidden. Offset Path 0 on the clip group leaves + // every path as it is, so the clip group draws the same with it and without it. let doc = |effect: bool, grouped: bool, hidden: bool| { let mut d = Document::new(100.0, 100.0); let style = CharStyle { size: 40.0, fill: Paint::None, ..Default::default() }; diff --git a/crates/render/src/tests/memory.rs b/crates/render/src/tests/memory.rs index 1f7a49698c..a9226da834 100644 --- a/crates/render/src/tests/memory.rs +++ b/crates/render/src/tests/memory.rs @@ -1667,6 +1667,16 @@ fn objects_with_plugin_effects_follow_the_plugins_installed() { plugin_change_draws_as_new(alone(20.0, vec![drop_shadow()]), ID, small.clone(), big.clone()); let blur = effect("blur.gaussian", serde_json::json!({"radius": 1.0})); plugin_change_draws_as_new(alone(20.0, vec![blur]), ID, small.clone(), big.clone()); + // A clip group whose clipping path, the square, has the plug-in effect: it clips a red + // rectangle over the whole view by the shape the plug-in draws. + let mut d = Document::new(200.0, 100.0); + let mut clip = square(&mut d, 20.0, 30.0, vec![]); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let red = filled(&mut d, rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), vec![]); + let group = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(red)], clip: true }); + plugin_change_draws_as_new(with_art(d, vec![group]), ID, small.clone(), big.clone()); // An instance of symbol `Shaped`, a group of the square around the origin. let mut d = Document::new(200.0, 100.0); let art = square(&mut d, -20.0, -20.0, vec![]); diff --git a/crates/svg/tests/it/effects_export.rs b/crates/svg/tests/it/effects_export.rs index 832e72c8f8..c1a19e8d53 100644 --- a/crates/svg/tests/it/effects_export.rs +++ b/crates/svg/tests/it/effects_export.rs @@ -102,6 +102,37 @@ fn a_strokes_own_glow_filters_the_stroke_element_only() { assert!(!export(&doc_with(n), &ExportOptions::default()).contains("").expect("its end")]; + body[body.find('>').unwrap()..].to_string() + }; + assert_eq!(clipped(0.0, true), clipped(50.0, false), "the clip path is written where the effect moves it"); + assert_ne!(clipped(0.0, true), clipped(0.0, false)); +} + #[test] fn geometry_effects_on_type_export_as_its_reshaped_outlines() { let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 50c7b1be30..6d40499106 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -457,5 +457,6 @@ impl VectorcraftApp { /// Show a transient status message. pub fn status(&mut self, s: impl Into) { self.ui.status = s.into(); + self.ui.status_sets = self.ui.status_sets.wrapping_add(1); } } diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index c870110434..b49b50881a 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -207,8 +207,8 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { items.push((tl!("Remove Anchor Points"), "pen-tool-delete", "path.removeAnchors", none())); items.push((tl!("Connect"), "dc-join", "path.join", none())); items.push((tl!("Cut Path"), "scissors", "path.cutAtAnchors", none())); - items.push((tl!("Corner"), "dc-anchor", TASK_BAR_TO_CORNER, none())); - items.push((tl!("Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH, none())); + items.push((tl!("Convert Selected Anchor Points to Corner"), "dc-anchor", TASK_BAR_TO_CORNER, none())); + items.push((tl!("Convert Selected Anchor Points to Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH, none())); } // As in the reference app, open paths offer Join and Edit Path, compound paths and clipping // masks their Release, images selected with vector art Make Clipping Mask, type converts @@ -257,14 +257,17 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } else if is_image { // Offset Path and Simplify don't apply to an image. } else if !anchors && direct { - // Corner and Smooth convert every anchor of a path selected whole; Remove and Cut - // need anchors picked out, so they wait for them. + // The two convert buttons convert every anchor of a path selected whole; Remove and Cut + // need anchors picked out, so they wait for them. Simplify and Smooth open their + // sliders, as Object › Path › Simplify… and Smooth… do; the page above names Smooth as + // adjusting the path's curvature, apart from converting its anchor points. if open_path { items.push((tl!("Connect"), "dc-join", "path.join", none())); } - items.push((tl!("Corner"), "dc-anchor", TASK_BAR_TO_CORNER, none())); - items.push((tl!("Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH, none())); + items.push((tl!("Convert Selected Anchor Points to Corner"), "dc-anchor", TASK_BAR_TO_CORNER, none())); + items.push((tl!("Convert Selected Anchor Points to Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH, none())); items.push((tl!("Simplify"), "spline", "object.path.simplify", none())); + items.push((tl!("Smooth"), "dc-smooth", "object.path.smooth", none())); } else if !anchors { if open_path { items.push((tl!("Join"), "dc-join", "path.join", none())); @@ -292,7 +295,7 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { .into_iter() .filter_map(|(shown, chip)| shown.then_some(chip)) .collect(); - let widths: Vec = items.iter().map(|(label, ..)| action_width(ui, label)).collect(); + let widths: Vec = items.iter().map(|(label, _, cmd, _)| if icon_only(cmd) { BAR_MORE } else { action_width(ui, label) }).collect(); let font_w = fonts.then(|| { let w = crate::panels::character::TASK_BAR_WIDTHS; w.iter().sum::() + BAR_GAP * (w.len() - 1) as f32 @@ -367,7 +370,17 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } } for ((label, icon, cmd, params), at) in items.iter().zip(&layout.actions) { - if *at == Some(row) && action_button(ui, &t, label, icon) { + if *at != Some(row) { + continue; + } + // The convert buttons show their icon alone, named by their hover text, as + // the Control bar's do. + let clicked = if icon_only(cmd) { + widgets::icon_button(ui, icon, label, false, BAR_MORE).clicked() + } else { + action_button(ui, &t, label, icon) + }; + if clicked { run = Some((*cmd, params.clone())); } } @@ -459,7 +472,7 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { Some((TASK_BAR_ARTBOARD_OPTIONS, _)) => crate::menus::invoke(app, "tool.options", json!({"tool": "artboard"})), Some((TASK_BAR_DUPLICATE_ARTBOARDS, _)) => crate::panels::artboards::duplicate_picked(app), Some((TASK_BAR_REARRANGE_ARTBOARDS, _)) => crate::panels::artboards::open_rearrange(app), - // Corner and Smooth convert the direct-selected anchors, as the Control bar's buttons do. + // The convert buttons convert the direct-selected anchors, as the Control bar's buttons do. Some((TASK_BAR_TO_CORNER, _)) => crate::menus::invoke(app, "path.convertAnchors", json!({"to": "corner"})), Some((TASK_BAR_TO_SMOOTH, _)) => crate::menus::invoke(app, "path.convertAnchors", json!({"to": "smooth"})), Some((c, p)) => crate::menus::invoke(app, c, p), @@ -664,6 +677,11 @@ fn paint_or_object_chip( } } +/// Whether the bar's item that runs `cmd` shows its icon alone: the anchor convert buttons. +fn icon_only(cmd: &str) -> bool { + matches!(cmd, TASK_BAR_TO_CORNER | TASK_BAR_TO_SMOOTH) +} + /// The paints the Fill and Stroke chips show, and whether each is mixed ("?"): the proxies', or /// while a Type tool edits type, the edited characters' fill, which a swatch picked then paints. pub(crate) fn chip_paints(app: &VectorcraftApp, ctx: &egui::Context) -> (vectorcraft_color::Paint, vectorcraft_color::Paint, bool, bool) { @@ -714,8 +732,11 @@ const TASK_BAR_DUPLICATE_ARTBOARDS: &str = "taskBar.duplicateArtboards"; /// The Contextual Task Bar's Rearrange All button with the Artboard tool. const TASK_BAR_REARRANGE_ARTBOARDS: &str = "taskBar.rearrangeArtboards"; -/// The Contextual Task Bar's Corner button for direct-selected anchors. +/// The Contextual Task Bar's Convert Selected Anchor Points to Corner button, for direct-selected +/// anchors or a path selected whole with Direct Selection. const TASK_BAR_TO_CORNER: &str = "taskBar.toCorner"; -/// The Contextual Task Bar's Smooth button for direct-selected anchors. +/// The Contextual Task Bar's Convert Selected Anchor Points to Smooth button, for direct-selected +/// anchors or a path selected whole with Direct Selection. The bar's Smooth button runs +/// `object.path.smooth` instead. const TASK_BAR_TO_SMOOTH: &str = "taskBar.toSmooth"; diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 4d609f2b5e..5ef3789905 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -10447,9 +10447,34 @@ fn the_artboard_tools_task_bar_buttons_act_on_the_selected_artboards() { assert_eq!(boards(&app), before, "one undo step"); } +/// The names and places of the buttons the Contextual Task Bar draws, from the accessibility tree +/// (`ctx` has it enabled): an icon-only button is named by its hover text. +fn task_bar_buttons(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<(String, egui::Rect)> { + frame(app, ctx, vec![]); + 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(); + let Some(bar) = task_bar_rect(ctx) else { return vec![] }; + let Some(update) = out.platform_output.accesskit_update.as_ref() else { return vec![] }; + let mut v: Vec<(String, egui::Rect)> = update + .nodes + .iter() + .filter(|(_, n)| n.role() == egui::accesskit::Role::Button) + .filter_map(|(_, n)| { + let b = n.bounds()?; + let r = egui::Rect::from_min_max(Pos2::new(b.x0 as f32, b.y0 as f32), Pos2::new(b.x1 as f32, b.y1 as f32)); + let label = n.label()?; + bar.contains(r.center()).then(|| (label.to_string(), r)) + }) + .collect(); + v.sort_by(|a, b| a.1.left().total_cmp(&b.1.left())); + v +} + /// With anchors direct-selected, the Contextual Task Bar offers Remove Anchor Points, Connect, -/// Cut Path, Corner and Smooth. Smooth and Corner convert the selected anchor, Cut Path opens -/// the path there and Connect closes it again, as the Control bar's anchor buttons do. +/// Cut Path and the two convert buttons, which show their icons alone. Converting to smooth and to +/// corner changes the selected anchor, Cut Path opens the path there and Connect closes it again, +/// as the Control bar's anchor buttons do. #[test] fn the_task_bar_connects_cuts_and_converts_direct_selected_anchors() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); @@ -10458,10 +10483,21 @@ fn the_task_bar_connects_cuts_and_converts_direct_selected_anchors() { app.select_tool("directSelection"); app.run("select.anchors", json!({"id": id, "anchors": [[0, 1]]})).unwrap(); let ctx = egui::Context::default(); + ctx.enable_accesskit(); let names = |app: &mut VectorcraftApp| task_bar_labels(app, &ctx).into_iter().map(|(t, _)| t).collect::>(); - assert_eq!(names(&mut app), ["Remove Anchor Points", "Connect", "Cut Path", "Corner", "Smooth", "Duplicate"]); + assert_eq!(names(&mut app), ["Remove Anchor Points", "Connect", "Cut Path", "Duplicate"]); + let buttons = task_bar_buttons(&mut app, &ctx).into_iter().map(|(t, _)| t).collect::>(); + let expected = [ + "Remove Anchor Points", + "Connect", + "Cut Path", + "Convert Selected Anchor Points to Corner", + "Convert Selected Anchor Points to Smooth", + "Duplicate", + ]; + assert_eq!(buttons[..expected.len()], expected, "{buttons:?}"); let press = |app: &mut VectorcraftApp, label: &str| { - let labels = task_bar_labels(app, &ctx); + let labels = task_bar_buttons(app, &ctx); let at = labels.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("{label} in {labels:?}")).1.center(); let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; frame(app, &ctx, vec![egui::Event::PointerMoved(at)]); @@ -10471,11 +10507,11 @@ fn the_task_bar_connects_cuts_and_converts_direct_selected_anchors() { let subpath = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().path_data().unwrap().subpaths[0].clone(); let last_undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.last().unwrap().label.clone(); - press(&mut app, "Smooth"); + press(&mut app, "Convert Selected Anchor Points to Smooth"); let a = subpath(&app).anchors[1]; assert!(a.has_in() && a.has_out(), "smooth: {a:?}"); assert_eq!(last_undo(&app), "Convert Anchor Points"); - press(&mut app, "Corner"); + press(&mut app, "Convert Selected Anchor Points to Corner"); let a = subpath(&app).anchors[1]; assert!(!a.has_in() && !a.has_out(), "corner: {a:?}"); press(&mut app, "Cut Path"); diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 592de77eed..882adefa39 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5876,6 +5876,7 @@ digits Malayalam Malaiàlam @msg no action set to save no hi ha cap conjunt d’accions per desar @msg the action set file can't be read in full el fitxer de conjunt d’accions no es pot llegir sencer @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out S’ha carregat “{name}”: s’ha omès {n} pas|S’ha carregat “{name}”: s’han omès {n} passos +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` S’ha carregat “{name}”: {n} pas té una ordre desconeguda i s’ha conservat: “{action}”, pas {step}, `{command}`|S’ha carregat “{name}”: {n} passos tenen ordres desconegudes i s’han conservat; el primer: “{action}”, pas {step}, `{command}` @msg not an action set file no és un fitxer de conjunt d’accions @msg the action set file is larger than 8 MiB el fitxer de conjunt d’accions supera els 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: la referència superaria els {MAX_BYTES} bytes @@ -6367,6 +6368,7 @@ digits Malayalam Malaiàlam @msg truncated truncat @msg Unembedded to {_1} S’ha desincrustat a {_1} @msg unknown command `{0}` ordre desconeguda `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` el pas {step} de “{action}” executa l’ordre desconeguda `{command}` @msg unknown command `{0}`; did you mean `{1}`? ordre desconeguda `{0}`; volies dir `{1}`? @msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode control desconegut `{name}`: fillStroke, coloring, drawMode o screenMode @msg Unknown dimension mode Mode de dimensió desconegut diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index bde13aa090..d95879fc03 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5055,6 +5055,7 @@ imageTrace Outline Obrys @msg no action set to save žádná sada akcí k uložení @msg the action set file can't be read in full soubor sady akcí nelze přečíst celý @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Načteno „{name}“: vynechán {n} krok|Načteno „{name}“: vynechány {n} kroky|Načteno „{name}“: vynecháno {n} kroků +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Načteno „{name}“: {n} krok má neznámý příkaz a byl ponechán: „{action}“, krok {step}, `{command}`|Načteno „{name}“: {n} kroky mají neznámé příkazy a byly ponechány; první: „{action}“, krok {step}, `{command}`|Načteno „{name}“: {n} kroků má neznámé příkazy a bylo ponecháno; první: „{action}“, krok {step}, `{command}` @msg not an action set file není to soubor sady akcí @msg the action set file is larger than 8 MiB soubor sady akcí je větší než 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: reference by byla větší než {MAX_BYTES} bajtů @@ -5295,6 +5296,7 @@ imageTrace Outline Obrys @msg tool shortcuts must be a single key (optionally with Shift) zkratky nástrojů musí být jediná klávesa (volitelně se Shift) @msg truncated zkráceno @msg unknown command `{0}` neznámý příkaz `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` krok {step} akce „{action}“ spouští neznámý příkaz `{command}` @msg unknown command `{0}`; did you mean `{1}`? neznámý příkaz `{0}`; mysleli jste `{1}`? @msg unknown effect `{id}` neznámý efekt `{id}` @msg unknown format neznámý formát diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 906a3bdbf9..d98afe5b03 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4669,6 +4669,7 @@ theme Appearance Erscheinungsbild @msg no action set to save kein Aktionssatz zum Speichern @msg the action set file can't be read in full die Aktionssatzdatei kann nicht vollständig gelesen werden @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out „{name}“ geladen: {n} Schritt wurde ausgelassen|„{name}“ geladen: {n} Schritte wurden ausgelassen +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` „{name}“ geladen: {n} Schritt hat einen unbekannten Befehl und wurde behalten: „{action}“, Schritt {step}, `{command}`|„{name}“ geladen: {n} Schritte haben unbekannte Befehle und wurden behalten; der erste: „{action}“, Schritt {step}, `{command}` @msg not an action set file keine Aktionssatzdatei @msg the action set file is larger than 8 MiB die Aktionssatzdatei ist größer als 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: die Referenz wäre größer als {MAX_BYTES} Byte @@ -4910,6 +4911,7 @@ theme Appearance Erscheinungsbild @msg tool shortcuts must be a single key (optionally with Shift) Tastaturbefehle für Werkzeuge müssen aus einer einzelnen Taste bestehen (optional mit Shift) @msg truncated abgeschnitten @msg unknown command `{0}` unbekannter Befehl `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` Schritt {step} von „{action}“ führt den unbekannten Befehl `{command}` aus @msg unknown command `{0}`; did you mean `{1}`? unbekannter Befehl `{0}`; meinten Sie `{1}`? @msg unknown effect `{id}` unbekannter Effekt `{id}` @msg unknown format unbekanntes Format diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 776c992e7c..8facc3b3dd 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4701,6 +4701,7 @@ digits Malayalam Μαλαγιάλαμ @msg no action set to save δεν υπάρχει σύνολο ενεργειών για αποθήκευση @msg the action set file can't be read in full το αρχείο συνόλου ενεργειών δεν μπορεί να διαβαστεί ολόκληρο @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Φορτώθηκε το «{name}»: παραλείφθηκε {n} βήμα|Φορτώθηκε το «{name}»: παραλείφθηκαν {n} βήματα +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Φορτώθηκε το «{name}»: {n} βήμα έχει άγνωστη εντολή και διατηρήθηκε: «{action}», βήμα {step}, `{command}`|Φορτώθηκε το «{name}»: {n} βήματα έχουν άγνωστες εντολές και διατηρήθηκαν· το πρώτο: «{action}», βήμα {step}, `{command}` @msg not an action set file δεν είναι αρχείο συνόλου ενεργειών @msg the action set file is larger than 8 MiB το αρχείο συνόλου ενεργειών είναι μεγαλύτερο από 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: η αναφορά θα ήταν μεγαλύτερη από {MAX_BYTES} byte @@ -4935,6 +4936,7 @@ digits Malayalam Μαλαγιάλαμ @msg tool shortcuts must be a single key (optionally with Shift) οι συντομεύσεις εργαλείων πρέπει να περιλαμβάνουν ένα πλήκτρο (προαιρετικά με Shift) @msg truncated περικομμένο @msg unknown command `{0}` άγνωστη εντολή `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` το βήμα {step} της ενέργειας «{action}» εκτελεί την άγνωστη εντολή `{command}` @msg unknown command `{0}`; did you mean `{1}`? άγνωστη εντολή `{0}`· μήπως εννοείτε `{1}`; @msg unknown effect `{id}` άγνωστο εφέ `{id}` @msg unknown format άγνωστη μορφή diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 935d011e72..3de5287c48 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4745,6 +4745,7 @@ axis Both Ambos @msg no action set to save no hay ningún conjunto de acciones que guardar @msg the action set file can't be read in full el archivo de conjunto de acciones no se puede leer completo @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Se cargó “{name}”: se omitió {n} paso|Se cargó “{name}”: se omitieron {n} pasos +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Se cargó “{name}”: {n} paso tiene un comando desconocido y se conservó: “{action}”, paso {step}, `{command}`|Se cargó “{name}”: {n} pasos tienen comandos desconocidos y se conservaron; el primero: “{action}”, paso {step}, `{command}` @msg not an action set file no es un archivo de conjunto de acciones @msg the action set file is larger than 8 MiB el archivo de conjunto de acciones supera los 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: la referencia superaría los {MAX_BYTES} bytes @@ -4986,6 +4987,7 @@ axis Both Ambos @msg tool shortcuts must be a single key (optionally with Shift) los atajos de herramientas deben ser una sola tecla (opcionalmente con Shift) @msg truncated truncado @msg unknown command `{0}` comando desconocido `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` el paso {step} de “{action}” ejecuta el comando desconocido `{command}` @msg unknown command `{0}`; did you mean `{1}`? comando desconocido `{0}`; ¿quiso decir `{1}`? @msg unknown effect `{id}` efecto desconocido `{id}` @msg unknown format formato desconocido diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 684589a9a1..d06a0bb02b 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4961,6 +4961,7 @@ imageTrace Outline Ääriviiva @msg no action set to save ei tallennettavaa toimintosarjaa @msg the action set file can't be read in full toimintosarjatiedostoa ei voi lukea kokonaan @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Ladattiin ”{name}”: jätettiin pois {n} vaihe|Ladattiin ”{name}”: jätettiin pois {n} vaihetta +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Ladattiin ”{name}”: {n} vaiheella on tuntematon komento, ja se säilytettiin: ”{action}”, vaihe {step}, `{command}`|Ladattiin ”{name}”: {n} vaiheella on tuntemattomia komentoja, ja ne säilytettiin; ensimmäinen: ”{action}”, vaihe {step}, `{command}` @msg not an action set file ei ole toimintosarjatiedosto @msg the action set file is larger than 8 MiB toimintosarjatiedosto on suurempi kuin 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: viite olisi suurempi kuin {MAX_BYTES} tavua @@ -5194,6 +5195,7 @@ imageTrace Outline Ääriviiva @msg tool shortcuts must be a single key (optionally with Shift) työkalujen pikanäppäinten on oltava yksittäinen näppäin (valinnaisesti Vaihto-näppäimen kanssa) @msg truncated katkaistu @msg unknown command `{0}` tuntematon komento `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` toiminnon ”{action}” vaihe {step} suorittaa tuntemattoman komennon `{command}` @msg unknown command `{0}`; did you mean `{1}`? tuntematon komento `{0}`; tarkoititko `{1}`? @msg unknown effect `{id}` tuntematon tehoste `{id}` @msg unknown format tuntematon muoto diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 9ceca79c90..3dfd5ad5d9 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4734,6 +4734,7 @@ axis Both Les deux @msg no action set to save aucun jeu de scripts à enregistrer @msg the action set file can't be read in full le fichier de jeu de scripts ne peut pas être lu en entier @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out « {name} » chargé : {n} étape a été ignorée|« {name} » chargé : {n} étapes ont été ignorées +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` « {name} » chargé : {n} étape a une commande inconnue et a été conservée : « {action} », étape {step}, `{command}`|« {name} » chargé : {n} étapes ont des commandes inconnues et ont été conservées ; la première : « {action} », étape {step}, `{command}` @msg not an action set file ce n’est pas un fichier de jeu de scripts @msg the action set file is larger than 8 MiB le fichier de jeu de scripts dépasse 8 Mio @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}` : la référence dépasserait {MAX_BYTES} octets @@ -4975,6 +4976,7 @@ axis Both Les deux @msg tool shortcuts must be a single key (optionally with Shift) les raccourcis d’outils doivent être une seule touche (éventuellement avec Maj) @msg truncated tronqué @msg unknown command `{0}` commande inconnue `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` l’étape {step} de « {action} » exécute la commande inconnue `{command}` @msg unknown command `{0}`; did you mean `{1}`? commande inconnue `{0}` ; vouliez-vous dire `{1}` ? @msg unknown effect `{id}` effet inconnu `{id}` @msg unknown format format inconnu diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 64873c5e2d..3c77aa0a99 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -7520,6 +7520,7 @@ antiAlias Sharp शार्प @msg not a Word document: its WordDocument stream doesn't start with a file information block वर्ड डॉक्यूमेंट नहीं: इसकी WordDocument धारा फ़ाइल सूचना ब्लॉक के साथ शुरू नहीं होती है @msg the action set file can't be read in full एक्शन सेट फ़ाइल पूरी तरह नहीं पढ़ी जा सकती @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” लोड किया गया: {n} चरण छोड़ा गया|“{name}” लोड किया गया: {n} चरण छोड़े गए +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` “{name}” लोड किया गया: {n} चरण में अज्ञात आदेश है और उसे रखा गया: “{action}”, चरण {step}, `{command}`|“{name}” लोड किया गया: {n} चरणों में अज्ञात आदेश हैं और उन्हें रखा गया; पहला: “{action}”, चरण {step}, `{command}` @msg not an action set file एक एक्शन सेट फ़ाइल नहीं @msg not an EMF or WMF file नहीं एक EMF या WMF फ़ाइल @msg not an HP-GL file HP-GL फ़ाइल नहीं है @@ -8410,6 +8411,7 @@ antiAlias Sharp शार्प @msg unknown character style number field अज्ञात चरित्र स्टाइल संख्या क्षेत्र @msg unknown CMYK profile `{name}` अज्ञात CMYK प्रोफ़ाइल `{name}` @msg unknown command `{0}` अज्ञात आदेश `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` “{action}” का चरण {step} अज्ञात आदेश `{command}` चलाता है @msg unknown command `{0}`; did you mean `{1}`? अज्ञात आदेश `{0}`; क्या आपको `{1}` का मतलब है? @msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode अज्ञात नियंत्रण `{name}`: fillStroke, रंग, drawMode या screenMode @msg Unknown dimension mode अज्ञात आयाम मोड diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index ddf2acf4c0..c4cdb9d544 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5434,6 +5434,7 @@ imageTrace Outline Körvonal @msg no action set to save nincs menthető műveletkészlet @msg the action set file can't be read in full a műveletkészlet-fájl nem olvasható be teljesen @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out „{name}” betöltve; kihagyott lépések: {n} +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` „{name}” betöltve; megtartott lépések ismeretlen paranccsal: {n}; az első: „{action}”, lépés: {step}, `{command}` @msg not an action set file nem műveletkészlet-fájl @msg the action set file is larger than 8 MiB a műveletkészlet-fájl nagyobb 8 MiB-nál @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: a referencia nagyobb lenne {MAX_BYTES} bájtnál @@ -5807,6 +5808,7 @@ imageTrace Outline Körvonal @msg truncated csonka @msg Unembedded to {_1} Beágyazás megszüntetve, ide: {_1} @msg unknown command `{0}` ismeretlen parancs: `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` „{action}”, lépés: {step}: ismeretlen parancsot futtat: `{command}` @msg unknown command `{0}`; did you mean `{1}`? ismeretlen parancs: `{0}`; erre gondolt: `{1}`? @msg unknown effect `{id}` ismeretlen effektus: `{id}` @msg unknown format ismeretlen formátum diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1252743764..534a7a181f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4797,6 +4797,7 @@ axis Both Entrambi @msg no action set to save nessun set di azioni da salvare @msg the action set file can't be read in full il file di set di azioni non può essere letto per intero @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Caricato “{name}”: {n} passaggio è stato omesso|Caricato “{name}”: {n} passaggi sono stati omessi +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Caricato “{name}”: {n} passaggio ha un comando sconosciuto ed è stato mantenuto: “{action}”, passaggio {step}, `{command}`|Caricato “{name}”: {n} passaggi hanno comandi sconosciuti e sono stati mantenuti; il primo: “{action}”, passaggio {step}, `{command}` @msg not an action set file non è un file di set di azioni @msg the action set file is larger than 8 MiB il file del set di azioni supera 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: il riferimento supererebbe {MAX_BYTES} byte @@ -5038,6 +5039,7 @@ axis Both Entrambi @msg tool shortcuts must be a single key (optionally with Shift) le scelte rapide degli strumenti devono essere un singolo tasto (eventualmente con Shift) @msg truncated troncato @msg unknown command `{0}` comando sconosciuto `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` il passaggio {step} di “{action}” esegue il comando sconosciuto `{command}` @msg unknown command `{0}`; did you mean `{1}`? comando sconosciuto `{0}`; forse intendevate `{1}`? @msg unknown effect `{id}` effetto sconosciuto `{id}` @msg unknown format formato sconosciuto diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 97a91c1ed2..98a188ee02 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4715,6 +4715,7 @@ swatch Bright Violet ビビッドバイオレット @msg objects can't be nested more than 32 deep オブジェクトは 32 階層より深く入れ子にできません @msg the SVG's elements are nested more than {0} deep SVG の要素が {0} 階層より深く入れ子になっています @msg unknown command `{0}` `{0}` は不明なコマンドです +@msg step {step} of “{action}” runs the unknown command `{command}` 「{action}」のステップ {step} は不明なコマンド `{command}` を実行します @msg unknown command `{0}`; did you mean `{1}`? `{0}` は不明なコマンドです。`{1}` のことですか? @msg `{what}` closes nothing `{what}` に対応する開始部分がありません @msg `{what}` closes something it didn't open `{what}` が対応しない開始部分を閉じています @@ -5064,6 +5065,7 @@ swatch Bright Violet ビビッドバイオレット @msg no action set to save 保存するアクションセットがありません @msg the action set file can't be read in full アクションセットファイルをすべて読み込めません @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out 「{name}」を読み込みました。{n} 個のステップは省きました +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` 「{name}」を読み込みました。不明なコマンドのステップ {n} 個はそのまま残しました。最初は「{action}」のステップ {step}、`{command}` です @msg not an action set file アクションセットファイルではありません @msg the action set file is larger than 8 MiB アクションセットファイルが 8 MiB を超えています @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: リファレンスが {MAX_BYTES} バイトを超えます diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 9891fa66f2..f2d20fc031 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4425,6 +4425,7 @@ font-technology Variable ცვლადი @msg no action set to save შესანახი მოქმედებების ნაკრები არ არის @msg the action set file can't be read in full მოქმედებების ნაკრების ფაილის სრულად წაკითხვა შეუძლებელია @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out ჩაიტვირთა „{name}“; გამოტოვებული ნაბიჯები: {n} +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` ჩაიტვირთა „{name}“; შენახული ნაბიჯები უცნობი ბრძანებით: {n}; პირველი: „{action}“, ნაბიჯი {step}, `{command}` @msg not an action set file ეს არ არის მოქმედებების ნაკრების ფაილი @msg the action set file is larger than 8 MiB მოქმედებების ნაკრების ფაილი 8 MiB-ზე დიდია @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: ცნობარი {MAX_BYTES} ბაიტზე დიდი იქნებოდა @@ -4644,6 +4645,7 @@ font-technology Variable ცვლადი @msg tool shortcuts must be a single key (optionally with Shift) ხელსაწყოს მალსახმობი ერთი კლავიში უნდა იყოს (სურვილისამებრ Shift-ით) @msg truncated შეკვეცილია @msg unknown command `{0}` უცნობი ბრძანება `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` „{action}“, ნაბიჯი {step}: უცნობი ბრძანება `{command}` @msg unknown command `{0}`; did you mean `{1}`? უცნობი ბრძანება `{0}`; იქნებ `{1}` იგულისხმეთ? @msg unknown effect `{id}` უცნობი ეფექტი `{id}` @msg unknown format უცნობი ფორმატი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 0cdcda85c8..8af8d3f53d 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4642,6 +4642,7 @@ theme Appearance 외관 @msg objects can't be nested more than 32 deep 개체는 32단계보다 깊게 중첩할 수 없습니다 @msg the SVG's elements are nested more than {0} deep SVG 요소가 {0}단계보다 깊게 중첩되어 있습니다 @msg unknown command `{0}` 알 수 없는 명령 `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` “{action}”의 단계 {step}에서 알 수 없는 명령 `{command}`을(를) 실행합니다 @msg unknown command `{0}`; did you mean `{1}`? 알 수 없는 명령 `{0}`: `{1}`을(를) 의미하셨습니까? @msg `{what}` closes nothing `{what}`이(가) 닫는 항목이 없습니다 @msg `{what}` closes something it didn't open `{what}`이(가) 열지 않은 항목을 닫습니다 @@ -4991,6 +4992,7 @@ theme Appearance 외관 @msg no action set to save 저장할 액션 세트가 없습니다 @msg the action set file can't be read in full 액션 세트 파일을 전부 읽을 수 없습니다 @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}”을(를) 불러왔습니다. 단계 {n}개를 제외했습니다 +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` “{name}”을(를) 불러왔습니다. 알 수 없는 명령이 있는 단계 {n}개를 그대로 두었습니다. 첫 번째: “{action}”, 단계 {step}, `{command}` @msg not an action set file 액션 세트 파일이 아닙니다 @msg the action set file is larger than 8 MiB 액션 세트 파일이 8 MiB보다 큽니다 @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: 참조가 {MAX_BYTES}바이트보다 커집니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index de753d62b2..c8b777d01b 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5112,6 +5112,7 @@ imageTrace Outline Contour @msg no action set to save geen actieset om op te slaan @msg the action set file can't be read in full het actiesetbestand kan niet volledig worden gelezen @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out „{name}” geladen: {n} stap is weggelaten|„{name}” geladen: {n} stappen zijn weggelaten +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` „{name}” geladen: {n} stap heeft een onbekende opdracht en is behouden: „{action}”, stap {step}, `{command}`|„{name}” geladen: {n} stappen hebben onbekende opdrachten en zijn behouden; de eerste: „{action}”, stap {step}, `{command}` @msg not an action set file geen actiesetbestand @msg the action set file is larger than 8 MiB het actiesetbestand is groter dan 8 MiB @msg the action set file holds more than 1,000 actions het actiesetbestand bevat meer dan 1.000 acties @@ -5313,6 +5314,7 @@ imageTrace Outline Contour @msg tool shortcuts must be a single key (optionally with Shift) gereedschapsneltoetsen moeten uit één toets bestaan (eventueel met Shift) @msg truncated afgekapt @msg unknown command `{0}` onbekende opdracht `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` stap {step} van „{action}” voert de onbekende opdracht `{command}` uit @msg unknown command `{0}`; did you mean `{1}`? onbekende opdracht `{0}`; bedoelde je `{1}`? @msg unknown effect `{id}` onbekend effect `{id}` @msg unknown format onbekende indeling diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 891e916724..1c49a7327a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4879,6 +4879,7 @@ imageTrace Outline Kontur @msg no action set to save brak zestawu operacji do zapisania @msg the action set file can't be read in full nie można odczytać całego pliku zestawu operacji @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Wczytano „{name}”: pominięto {n} krok|Wczytano „{name}”: pominięto {n} kroki|Wczytano „{name}”: pominięto {n} kroków +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Wczytano „{name}”: {n} krok ma nieznane polecenie i został zachowany: „{action}”, krok {step}, `{command}`|Wczytano „{name}”: {n} kroki mają nieznane polecenia i zostały zachowane; pierwszy: „{action}”, krok {step}, `{command}`|Wczytano „{name}”: {n} kroków ma nieznane polecenia i zostało zachowanych; pierwszy: „{action}”, krok {step}, `{command}` @msg not an action set file to nie jest plik zestawu operacji @msg the action set file is larger than 8 MiB plik zestawu operacji jest większy niż 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: dokumentacja byłaby większa niż {MAX_BYTES} bajtów @@ -5122,6 +5123,7 @@ imageTrace Outline Kontur @msg tool shortcuts must be a single key (optionally with Shift) skróty narzędzi muszą być pojedynczym klawiszem (opcjonalnie z Shift) @msg truncated obcięty @msg unknown command `{0}` nieznane polecenie `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` krok {step} operacji „{action}” uruchamia nieznane polecenie `{command}` @msg unknown command `{0}`; did you mean `{1}`? nieznane polecenie `{0}`; czy chodziło o `{1}`? @msg unknown effect `{id}` nieznany efekt `{id}` @msg unknown format nieznany format diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index e45bf18d42..12647b2884 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4199,6 +4199,7 @@ axis Both Ambos @msg no action set to save nenhum conjunto de ações para salvar @msg the action set file can't be read in full o arquivo de conjunto de ações não pode ser lido por inteiro @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” carregado: {n} etapa foi omitida|“{name}” carregado: {n} etapas foram omitidas +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` “{name}” carregado: {n} etapa tem um comando desconhecido e foi mantida: “{action}”, etapa {step}, `{command}`|“{name}” carregado: {n} etapas têm comandos desconhecidos e foram mantidas; a primeira: “{action}”, etapa {step}, `{command}` @msg not an action set file não é um arquivo de conjunto de ações @msg the action set file is larger than 8 MiB o arquivo de conjunto de ações tem mais de 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: a referência teria mais de {MAX_BYTES} bytes @@ -4438,6 +4439,7 @@ axis Both Ambos @msg tool shortcuts must be a single key (optionally with Shift) os atalhos de ferramenta devem ser uma única tecla (opcionalmente com Shift) @msg truncated truncado @msg unknown command `{0}` comando desconhecido `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` a etapa {step} de “{action}” executa o comando desconhecido `{command}` @msg unknown command `{0}`; did you mean `{1}`? comando desconhecido `{0}`; você quis dizer `{1}`? @msg unknown effect `{id}` efeito desconhecido `{id}` @msg unknown format formato desconhecido diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b260a39c0f..b3ec6a7684 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4782,6 +4782,7 @@ axis Both Оба @msg no action set to save нет набора действий для сохранения @msg the action set file can't be read in full файл набора действий невозможно прочитать целиком @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Загружено «{name}»: пропущен {n} шаг|Загружено «{name}»: пропущено {n} шага|Загружено «{name}»: пропущено {n} шагов +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Загружено «{name}»: {n} шаг с неизвестной командой сохранён: «{action}», шаг {step}, `{command}`|Загружено «{name}»: {n} шага с неизвестными командами сохранены; первый: «{action}», шаг {step}, `{command}`|Загружено «{name}»: {n} шагов с неизвестными командами сохранены; первый: «{action}», шаг {step}, `{command}` @msg not an action set file это не файл набора действий @msg the action set file is larger than 8 MiB файл набора действий больше 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: справочник был бы больше {MAX_BYTES} байт @@ -5023,6 +5024,7 @@ axis Both Оба @msg tool shortcuts must be a single key (optionally with Shift) сочетания инструментов должны быть одной клавишей (необязательно с Shift) @msg truncated усечено @msg unknown command `{0}` неизвестная команда `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` шаг {step} действия «{action}» выполняет неизвестную команду `{command}` @msg unknown command `{0}`; did you mean `{1}`? неизвестная команда `{0}`; возможно, имелась в виду `{1}`? @msg unknown effect `{id}` неизвестный эффект `{id}` @msg unknown format неизвестный формат diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 3854b8d56f..e16a0cb6e1 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4645,6 +4645,7 @@ axis Both Båda @msg no action set to save ingen åtgärdsuppsättning att spara @msg the action set file can't be read in full filen med åtgärdsuppsättning kan inte läsas helt @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Läste in ”{name}”: {n} steg utelämnades|Läste in ”{name}”: {n} steg utelämnades +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Läste in ”{name}”: {n} steg har ett okänt kommando och behölls: ”{action}”, steg {step}, `{command}`|Läste in ”{name}”: {n} steg har okända kommandon och behölls; det första: ”{action}”, steg {step}, `{command}` @msg not an action set file inte en fil med åtgärdsuppsättning @msg the action set file is larger than 8 MiB filen med åtgärdsuppsättningen är större än 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: referensen skulle bli större än {MAX_BYTES} byte @@ -4879,6 +4880,7 @@ axis Both Båda @msg tool shortcuts must be a single key (optionally with Shift) kortkommandon för verktyg måste vara en enda tangent (eventuellt med Shift) @msg truncated avkortad @msg unknown command `{0}` okänt kommando `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` steg {step} i ”{action}” kör det okända kommandot `{command}` @msg unknown command `{0}`; did you mean `{1}`? okänt kommando `{0}`; menade du `{1}`? @msg unknown effect `{id}` okänd effekt `{id}` @msg unknown format okänt format diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index e51e321781..c303810eb9 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -2524,6 +2524,10 @@ const COUNTED_STATUS_MESSAGES: &[(&str, &str)] = &[ ("Downloaded 1 file", "Downloaded 2 files"), ("Exported 1 file to /out", "Exported 2 files to /out"), ("Loaded “Tidy”: 1 step was left out", "Loaded “Tidy”: 2 steps were left out"), + ( + "Loaded “Tidy”: 1 step has an unknown command and was kept: “Export”, step 4, `example.finish`", + "Loaded “Tidy”: 2 steps have unknown commands and were kept; the first: “Export”, step 4.2, `example.finish`", + ), ]; /// The link, font, export and action set status messages name a count of 1 in the singular and other counts in diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 17575e2732..26822bf63e 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4479,6 +4479,7 @@ axis Both Her İkisi @msg no action set to save kaydedilecek eylem seti yok @msg the action set file can't be read in full eylem seti dosyası tamamen okunamıyor @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” yüklendi: {n} adım atlandı +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` “{name}” yüklendi: bilinmeyen komutlu {n} adım korundu; ilki: “{action}”, adım {step}, `{command}` @msg not an action set file bir eylem seti dosyası değil @msg the action set file is larger than 8 MiB eylem seti dosyası 8 MiB'den büyük @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: başvuru {MAX_BYTES} bayttan büyük olur @@ -4702,6 +4703,7 @@ axis Both Her İkisi @msg tool shortcuts must be a single key (optionally with Shift) araç kısayolları tek tuş olmalıdır (isteğe bağlı olarak Shift ile) @msg truncated kesik @msg unknown command `{0}` bilinmeyen komut `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` “{action}” eylemi, adım {step}: bilinmeyen `{command}` komutunu çalıştırıyor @msg unknown command `{0}`; did you mean `{1}`? bilinmeyen komut `{0}`; `{1}` mi demek istediniz? @msg unknown effect `{id}` bilinmeyen etki `{id}` @msg unknown format bilinmeyen format diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a92fb0329b..aa8d0c56f9 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4781,6 +4781,7 @@ axis Both Обидві @msg no action set to save немає набору дій для збереження @msg the action set file can't be read in full файл набору дій неможливо прочитати повністю @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Завантажено «{name}»: пропущено {n} крок|Завантажено «{name}»: пропущено {n} кроки|Завантажено «{name}»: пропущено {n} кроків +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Завантажено «{name}»: {n} крок із невідомою командою збережено: «{action}», крок {step}, `{command}`|Завантажено «{name}»: {n} кроки з невідомими командами збережено; перший: «{action}», крок {step}, `{command}`|Завантажено «{name}»: {n} кроків із невідомими командами збережено; перший: «{action}», крок {step}, `{command}` @msg not an action set file це не файл набору дій @msg the action set file is larger than 8 MiB файл набору дій більший за 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: довідник був би більшим за {MAX_BYTES} байтів @@ -5022,6 +5023,7 @@ axis Both Обидві @msg tool shortcuts must be a single key (optionally with Shift) поєднання інструментів має бути однією клавішею (необов’язково з Shift) @msg truncated усічено @msg unknown command `{0}` невідома команда `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` крок {step} дії «{action}» виконує невідому команду `{command}` @msg unknown command `{0}`; did you mean `{1}`? невідома команда `{0}`; можливо, ви мали на увазі `{1}`? @msg unknown effect `{id}` невідомий ефект `{id}` @msg unknown format невідомий формат diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 2f53625bf8..7b7480cbfd 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5094,6 +5094,7 @@ theme Appearance Giao diện @msg no action set to save không có bộ hành động nào để lưu @msg the action set file can't be read in full không thể đọc hết tệp bộ hành động @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Đã tải “{name}”: bỏ qua {n} bước +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Đã tải “{name}”: giữ lại {n} bước có lệnh không xác định; bước đầu tiên: “{action}”, bước {step}, `{command}` @msg not an action set file không phải tệp bộ hành động @msg the action set file is larger than 8 MiB tệp bộ hành động lớn hơn 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: tài liệu tham khảo sẽ lớn hơn {MAX_BYTES} byte @@ -5535,6 +5536,7 @@ theme Appearance Giao diện @msg truncated bị cắt ngắn @msg Unembedded to {_1} Đã bỏ nhúng vào {_1} @msg unknown command `{0}` lệnh không xác định `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` bước {step} của “{action}” chạy lệnh không xác định `{command}` @msg unknown command `{0}`; did you mean `{1}`? lệnh không xác định `{0}`; ý bạn là `{1}`? @msg unknown effect `{id}` hiệu ứng không xác định `{id}` @msg unknown format định dạng không xác định diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 8a7b09aa41..e1ec9f659c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4409,6 +4409,7 @@ imageTrace Outline 轮廓 @msg objects can't be nested more than 32 deep 对象的嵌套不能超过 32 层 @msg the SVG's elements are nested more than {0} deep SVG 的元素嵌套超过 {0} 层 @msg unknown command `{0}` 未知命令 `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` “{action}”的步骤 {step} 运行未知命令 `{command}` @msg unknown command `{0}`; did you mean `{1}`? 未知命令 `{0}`;您是否想输入 `{1}`? @msg `{what}` closes nothing `{what}` 没有可结束的内容 @msg `{what}` closes something it didn't open `{what}` 结束了并非由它开始的内容 @@ -4744,6 +4745,7 @@ imageTrace Outline 轮廓 @msg no action set to save 没有要存储的动作组 @msg the action set file can't be read in full 无法完整读取动作组文件 @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out 已载入“{name}”:已略过 {n} 个步骤 +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` 已载入“{name}”:保留了 {n} 个含未知命令的步骤;第一个:“{action}”,步骤 {step},`{command}` @msg not an action set file 不是动作组文件 @msg the action set file is larger than 8 MiB 动作组文件大于 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`:参考将大于 {MAX_BYTES} 字节 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 4deead50de..264a252f18 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2120,6 +2120,7 @@ file-filter Templates 範本 @msg objects can't be nested more than 32 deep 物件的巢狀層級不能超過 32 層 @msg the SVG's elements are nested more than {0} deep SVG 的元素巢狀超過 {0} 層 @msg unknown command `{0}` 未知的指令 `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` 「{action}」的步驟 {step} 執行未知的指令 `{command}` @msg unknown command `{0}`; did you mean `{1}`? 未知的指令 `{0}`;您是指 `{1}` 嗎? @msg `{what}` closes nothing `{what}` 沒有可結束的內容 @msg `{what}` closes something it didn't open `{what}` 結束了並非由它開始的內容 @@ -2458,6 +2459,7 @@ file-filter Templates 範本 @msg no action set to save 沒有要儲存的動作組 @msg the action set file can't be read in full 無法完整讀取動作組檔案 @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out 已載入「{name}」:已略過 {n} 個步驟 +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` 已載入「{name}」:保留了 {n} 個含未知指令的步驟;第一個:「{action}」,步驟 {step},`{command}` @msg not an action set file 不是動作組檔案 @msg the action set file is larger than 8 MiB 動作組檔案大於 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`:參考將大於 {MAX_BYTES} 位元組 diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 4a8e9910cb..af9252c333 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1265,7 +1265,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.smoothAuto", "Auto-Smooth", "", - "{} the open Smooth slider (Object › Path › Smooth and the canvas context menu's Smooth… open it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open", + "{} the open Smooth slider (Object › Path › Smooth, the canvas context menu's Smooth… and the Contextual Task Bar's Smooth open it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open", ), ( "ui.simplifyMoreOptions", diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 20a57b7d63..eff4796601 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -85,6 +85,70 @@ impl Action { }) .collect() } + + /// The steps whose command `known` does not hold, in order, as (step index, the index of the + /// command in a `command.batch` step, command id). A `command.batch` step's own commands are + /// checked one by one, as playback runs them ([`Action::included_commands`]); a command of one + /// with no command id has the id "". With `included_only`, excluded steps are passed over. + pub(crate) fn unknown_commands<'a>( + &'a self, + known: &'a KnownCommands, + included_only: bool, + ) -> impl Iterator, &'a str)> + 'a { + self.steps.iter().enumerate().filter(move |(i, _)| !(included_only && self.excluded_steps.contains(i))).flat_map( + |(i, (command, params))| -> Vec<(usize, Option, &'a str)> { + match params.get("commands").and_then(Value::as_array) { + Some(steps) if command == "command.batch" => steps + .iter() + .enumerate() + .map(|(j, step)| (i, Some(j), step.get("command").and_then(Value::as_str).unwrap_or_default())) + .filter(|(_, _, id)| !known.has(id)) + .collect(), + _ if known.has(command) => vec![], + _ => vec![(i, None, command.as_str())], + } + }, + ) + } +} + +/// The command ids a step of an action can run now: the built-in engine commands, the commands the +/// installed plug-ins add, the View menu commands the app plays itself ([`crate::menus::plays_in_app`]) +/// and stops ([`STOP_STEP`]). +pub(crate) struct KnownCommands(std::collections::HashSet<&'static str>); + +impl KnownCommands { + /// The command ids known when it is called: a plug-in installed or removed later is not in it. + pub(crate) fn now() -> Self { + let engine = vectorcraft_engine::command_specs().iter().map(|c| c.id); + let plugins = vectorcraft_engine::cmd::plugin::command_specs().into_iter().map(|c| c.id); + Self(engine.chain(plugins).chain([STOP_STEP]).collect()) + } + + /// Whether a step can run command `id`. + pub(crate) fn has(&self, id: &str) -> bool { + self.0.contains(id) || crate::menus::plays_in_app(id) + } +} + +/// The one-based place of a step that [`Action::unknown_commands`] gives: `3` for the third step, +/// `3.2` for the second command of a `command.batch` third step. +pub(crate) fn step_place(step: usize, in_batch: Option) -> String { + match in_batch { + Some(j) => format!("{}.{}", step + 1, j + 1), + None => (step + 1).to_string(), + } +} + +/// `text` as a status message shows it: control characters become `�`, and text past 80 +/// characters ends in `…`. +pub(crate) fn shown_text(text: &str) -> String { + const MAX: usize = 80; + let mut shown: String = text.chars().take(MAX).map(|c| if c.is_control() { '\u{fffd}' } else { c }).collect(); + if text.chars().nth(MAX).is_some() { + shown.push('…'); + } + shown } /// An action id no other action in this run has. @@ -257,7 +321,10 @@ pub fn default_sets() -> Vec { /// Accelerated runs every included step at once as a single undoable batch → what `command.batch` /// returns, {results}. Step by Step and Pause For run the first step now, each as its own undo step, /// and leave the rest to [`tick_playback`] → {results: [the first step's result], pending: the steps -/// left}. While a paced playback is in progress, another action does not play. +/// left}. While a paced playback is in progress, another action does not play. An action with an +/// included step whose command is not known now ([`KnownCommands`]), a plug-in's command after the +/// plug-in was removed for one, does not play: the error names the first such step and its command, +/// and no step runs. An excluded step is not checked. /// /// An action with an included stop ([`STOP_STEP`]) plays up to the stop and waits there at any speed /// ([`continue_playback`]). At Accelerated, the steps between two stops are one undo step. → {results: @@ -275,6 +342,13 @@ pub fn play(app: &mut VectorcraftApp, set: usize, idx: usize) -> Result Result { /// Actions… would write as a file Load Actions… refuses (larger than 8 MiB, as a file with less /// white space or a name that gets a number can give) is refused too, and no set is added. /// +/// A step whose command is not known now ([`super::KnownCommands`]), excluded or not, and a command +/// of a `command.batch` step that is not, still loads as it is. The answer then also lists them, +/// and the status bar names the first and how many there are → `{…, unknownCommands: [{action, +/// actionIndex, step, stepIndex, batchStep?, command}] (at most 1,000), unknownCount: n}`. +/// /// An action set another app saved as text (`.aia`, read with `actionSet.read`) comes in with its /// actions and the steps that stand for a VectorCraft command ([`step_name`], [`step_commands`]), /// with no params; a step that is off in the file is excluded from playback. A set with no name is @@ -537,7 +543,47 @@ pub fn load_set(app: &mut VectorcraftApp, p: &Value) -> Result { set.name = free_set_name(&app.ui.action_sets, &set.name); // A number added to the name can make a file at the limit larger than it. super::edit_keeps_set_loadable(app, None, &set)?; + let (unknown, unknown_count, first) = unknown_commands(&set); let (name, actions) = (set.name.clone(), set.actions.len()); app.ui.action_sets.push(set); - Ok(json!({ "set": app.ui.action_sets.len() - 1, "name": name, "actions": actions })) + let mut answer = json!({ "set": app.ui.action_sets.len() - 1, "name": name, "actions": actions }); + if let Some((action, step, command)) = first { + app.status(crate::i18n::msg_n( + unknown_count as u64, + "Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`", + "Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}`", + &[ + ("name", &super::shown_text(&name)), + ("action", &super::shown_text(&action)), + ("step", &step), + ("command", &super::shown_text(&command)), + ], + )); + answer["unknownCommands"] = json!(unknown); + answer["unknownCount"] = json!(unknown_count); + } + Ok(answer) +} + +/// The steps of `set` whose command is not known now ([`super::KnownCommands`]), excluded steps +/// included: the first [`MAX_SKIPPED`] as `[{action, actionIndex, step, stepIndex, batchStep?, +/// command}]` (`step` and `batchStep` count from 1, the indexes from 0), how many there are, and the +/// first one's action name, place ([`super::step_place`]) and command. +fn unknown_commands(set: &ActionSet) -> (Vec, usize, Option<(String, String, String)>) { + let known = super::KnownCommands::now(); + let (mut listed, mut count, mut first) = (vec![], 0usize, None); + for (a, action) in set.actions.iter().enumerate() { + for (step, in_batch, command) in action.unknown_commands(&known, false) { + count += 1; + first.get_or_insert_with(|| (action.name.clone(), super::step_place(step, in_batch), command.to_string())); + if listed.len() < MAX_SKIPPED { + let mut entry = json!({ "action": action.name, "actionIndex": a, "step": step + 1, "stepIndex": step, "command": command }); + if let Some(j) = in_batch { + entry["batchStep"] = json!(j + 1); + } + listed.push(entry); + } + } + } + (listed, count, first) } diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index aa0a25e50f..9d787954ea 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -125,24 +125,37 @@ fn write(app: &mut VectorcraftApp, store: &Arc, pick: impl Fn for job in jobs { let (uid, label, title) = (job.uid, format!("Saving recovery data for {}", job.title), job.title.clone()); let (done, at, saving) = (job.clone(), place.clone(), label.clone()); + // The status line before the job starts: on the worker, the job sets one line ("