Merge main into fix/text-wrap-offset-refused

This commit is contained in:
dotCooCoo
2026-11-14 06:09:19 -08:00
69 changed files with 2150 additions and 148 deletions
+24 -9
View File
@@ -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<Node> {
return bake_leaf(d, n);
}
let mut m = n.clone();
let baked: Vec<Arc<Node>> = 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<Arc<Node>> = 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<Node> {
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();
+3 -3
View File
@@ -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]
+117 -2
View File
@@ -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<Node> {
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<Node> {
let mut m: Option<Node> = None;
let members: &[std::sync::Arc<Node>] = 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
+11 -3
View File
@@ -845,9 +845,10 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> {
/// 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;
+5 -3
View File
@@ -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");
}
+14 -4
View File
@@ -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<Node> {
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,
};
+1
View File
@@ -4,6 +4,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes};
use super::*;
mod clipfx;
mod imageoutline;
mod painted;
mod rasterfollow;
+401
View File
@@ -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<Effect>) -> 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<Node> {
fn first(n: &Arc<Node>) -> Option<Arc<Node>> {
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<Effect>| {
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<Effect>| {
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<Rect> {
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::<usize>()
}
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<Arc<Node>> {
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<NodeId>) {
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));
}
}
+361
View File
@@ -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<usize> {
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<usize>, Vec<usize>)> {
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::<usize>().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<usize> =
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<vectorcraft_doc::Effect>) -> Node {
+83 -12
View File
@@ -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<images::Read>)>,
/// 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, (Arc<Vec<u8>>, 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<vectorcraft_geom::BezPath>,
/// The copy of an image that the last [`Self::image`] wrote as a window, when it did.
windowed: Option<images::Windowed>,
}
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::<usize>().ok().and_then(|i| self.payloads.get_mut(i)) {
if let Some((i, p)) = index.parse::<usize>().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
+2 -2
View File
@@ -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;
+93 -7
View File
@@ -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<usize>,
/// 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<Read> {
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<u8>) -> String {
self.payloads.push(bytes);
+2 -2
View File
@@ -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) {
@@ -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;
+2 -2
View File
@@ -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,
+50 -1
View File
@@ -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<u8> {
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 {
+2 -1
View File
@@ -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`,
+5
View File
@@ -599,6 +599,10 @@ struct Reader<'a> {
compound_groups: HashSet<NodeId>,
/// Groups of a text object and its look (see [`Self::style_marker`]), which aren't a look.
text_looks: HashSet<NodeId>,
/// 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<NodeId>,
/// 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,
+32 -5
View File
@@ -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,
+1 -2
View File
@@ -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",
+28 -3
View File
@@ -1445,13 +1445,38 @@ fn offset_stroke(f: &Filter) -> Result<Option<StyleItem>, ()> {
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<StyleItem> {
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 {
+67 -1
View File
@@ -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 {
+41
View File
@@ -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::<usize>()
};
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]
+52 -9
View File
@@ -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<BezPath>,
/// 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<Arc<image::DynamicImage>>)>,
/// The images written whole so far, by the address of their data (which each entry keeps).
whole_images: std::collections::HashMap<usize, Arc<Vec<u8>>>,
}
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<Arc<image::DynamicImage>> {
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`.
+1 -1
View File
@@ -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",
+20 -2
View File
@@ -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
+4 -1
View File
@@ -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));
}
}
+73
View File
@@ -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);
+3 -3
View File
@@ -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() };
+10
View File
@@ -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![]);
+31
View File
@@ -102,6 +102,37 @@ fn a_strokes_own_glow_filters_the_stroke_element_only() {
assert!(!export(&doc_with(n), &ExportOptions::default()).contains("<filter "));
}
#[test]
fn a_clipping_paths_transform_effect_moves_the_written_clip_path() {
// A clip group whose clipping path covers x 0 to 50; `effect` moves it 50 pt right.
let clipped = |x0: f64, effect: bool| {
let mut clip =
Node::path(NodeId(0), shapes::rectangle(Rect::new(x0, 0.0, x0 + 50.0, 100.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
if effect {
clip.appearance.effects.push(Effect { id: "distort.transform".into(), params: json!({"moveH": 50}), visible: true });
}
let art = Node::path(
NodeId(0),
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)),
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
);
let mut d = Document::new(200.0, 200.0);
let (clip, art) = (Node { id: d.alloc_id(), ..clip }, Node { id: d.alloc_id(), ..art });
let g = Node::new(d.alloc_id(), NodeKind::Group { children: vec![std::sync::Arc::new(clip), std::sync::Arc::new(art)], clip: true });
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
let svg = export(&d, &ExportOptions::default());
let open = svg.find("<clipPath").expect("a clip path");
let body = &svg[open..open + svg[open..].find("</clipPath>").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() };
+1
View File
@@ -457,5 +457,6 @@ impl VectorcraftApp {
/// Show a transient status message.
pub fn status(&mut self, s: impl Into<String>) {
self.ui.status = s.into();
self.ui.status_sets = self.ui.status_sets.wrapping_add(1);
}
}
+32 -11
View File
@@ -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<f32> = items.iter().map(|(label, ..)| action_width(ui, label)).collect();
let widths: Vec<f32> = 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::<f32>() + 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";
+42 -6
View File
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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");
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it is too large.
+2
View File
@@ -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
Can't render this file because it is too large.
+2
View File
@@ -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 άγνωστη μορφή
Can't render this file because it is too large.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it is too large.
+2
View File
@@ -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 अज्ञात आयाम मोड
Can't render this file because it is too large.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it is too large.
+2
View File
@@ -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} バイトを超えます
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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 უცნობი ფორმატი
Can't render this file because it is too large.
+2
View File
@@ -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}바이트보다 커집니다
Can't render this file because it is too large.
+2
View File
@@ -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
Can't render this file because it is too large.
+2
View File
@@ -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
Can't render this file because it is too large.
+2
View File
@@ -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
Can't render this file because it is too large.
+2
View File
@@ -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 неизвестный формат
Can't render this file because it is too large.
+2
View File
@@ -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
Can't render this file because it is too large.
+4
View File
@@ -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
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2
View File
@@ -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 невідомий формат
Can't render this file because it is too large.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 204 and column 5.
+2
View File
@@ -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} 字节
Can't render this file because it is too large.
+2
View File
@@ -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} 位元組
Can't render this file because it is too large.
+1 -1
View File
@@ -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",
+184 -2
View File
@@ -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<Item = (usize, Option<usize>, &'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<usize>, &'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<usize>) -> 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<ActionSet> {
/// 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<Value, S
if app.ui.action_playing.is_some() {
return Err("an action is already playing".into());
}
// A step whose command is not known now would fail only when playback reaches it, after the
// steps before it changed the document.
let known = KnownCommands::now();
if let Some((step, in_batch, command)) = a.unknown_commands(&known, true).next() {
let (step, action, command) = (step_place(step, in_batch), shown_text(&a.name), shown_text(command));
return Err(format!("step {step} of “{action}” runs the unknown command `{command}`"));
}
let options = app.ui.action_playback;
if has_stop(&commands) {
return play_with_stops(app, a.name, commands, options);
@@ -3907,6 +3981,112 @@ mod tests {
assert_eq!(set_names(&app), before, "no set added");
}
/// A step whose command is not known loads as it is, excluded or not and in a `command.batch`
/// step too, from `data` and from `path`: the answer lists each one and the status bar names the
/// first and how many there are. A set whose commands are all known loads with neither.
#[test]
fn load_actions_reports_steps_with_unknown_commands() {
let mut app = app_with_sets();
let files = with_files(&mut app, "/sets/unused.vcactions");
let batch = json!({"commands": [{"command": "object.move", "params": {"dx": 1, "dy": 0}}, {"command": "example.unknown.inner"}]});
let export = json!([
{"command": "object.move", "params": {"dx": 1, "dy": 0}},
{"command": "view.zoomIn", "params": {}},
{"command": STOP_STEP, "params": {"message": "", "allowContinue": false}},
{"command": "example.unknown.finish", "params": {"x": [1, 2]}},
{"command": "command.batch", "params": batch},
{"command": "example.unknown.off", "params": {}, "excluded": true},
]);
let tidy = json!({"name": "Tidy", "steps": [{"command": "object.move", "params": {"dx": 1, "dy": 0}}]});
let file = json!({"format": "vcactions", "version": 6, "name": "Print", "actions": [tidy, {"name": "Export", "steps": export}]}).to_string();
let want = json!([
{"action": "Export", "actionIndex": 1, "step": 4, "stepIndex": 3, "command": "example.unknown.finish"},
{"action": "Export", "actionIndex": 1, "step": 5, "stepIndex": 4, "batchStep": 2, "command": "example.unknown.inner"},
{"action": "Export", "actionIndex": 1, "step": 6, "stepIndex": 5, "command": "example.unknown.off"},
]);
let r = app.run(LOAD_SET, json!({"data": file})).unwrap();
assert_eq!(r, json!({"set": 3, "name": "Print", "actions": 2, "unknownCommands": want, "unknownCount": 3}));
assert_eq!(
app.ui.status,
"Loaded “Print”: 3 steps have unknown commands and were kept; the first: “Export”, step 4, `example.unknown.finish`"
);
let loaded = &app.ui.action_sets[3].actions[1];
assert_eq!(loaded.steps.len(), 6, "every step is kept");
assert_eq!(loaded.steps[3], ("example.unknown.finish".to_string(), json!({"x": [1, 2]})), "with its params");
assert_eq!(loaded.excluded_steps, BTreeSet::from([5]));
files.borrow_mut().insert("/sets/Print.vcactions".into(), file.into_bytes());
let r = app.run(LOAD_SET, json!({"path": "/sets/Print.vcactions"})).unwrap();
assert_eq!((r["name"].clone(), r["unknownCommands"].clone(), r["unknownCount"].clone()), (json!("Print 2"), want, json!(3)));
let saved = files::write_set(&app.ui.action_sets[4]);
assert!(saved.contains("example.unknown.finish") && saved.contains("example.unknown.inner"), "Save Actions… writes them back");
app.ui.status.clear();
let known = files::write_set(&app.ui.action_sets[1]);
assert_eq!(app.run(LOAD_SET, json!({"data": known})).unwrap(), json!({"set": 5, "name": "Mine 2", "actions": 2}));
assert_eq!(app.ui.status, "", "no message for a set whose commands are all known");
let empty = json!({"format": "vcactions", "version": 1, "name": "Empty", "actions": []}).to_string();
assert_eq!(app.run(LOAD_SET, json!({"data": empty})).unwrap(), json!({"set": 6, "name": "Empty", "actions": 0}));
assert_eq!(app.ui.status, "");
}
/// Load Actions… lists the first 1,000 steps with an unknown command and counts them all, and
/// the status bar shows an action name or command id with no control character and at most 80
/// characters, while the set and the answer keep them as they are. One step is named in the
/// singular.
#[test]
fn load_actions_bounds_what_it_reports_of_unknown_commands() {
let mut app = app_with_sets();
let steps = vec![json!({"command": "example.unknown"}); 1001];
let many = json!({"format": "vcactions", "version": 1, "name": "Many", "actions": [{"name": "A", "steps": steps}]});
let r = app.run(LOAD_SET, json!({"data": many.to_string()})).unwrap();
assert_eq!((r["unknownCommands"].as_array().map(Vec::len), r["unknownCount"].clone()), (Some(1000), json!(1001)));
assert_eq!(r["unknownCommands"][999], json!({"action": "A", "actionIndex": 0, "step": 1000, "stepIndex": 999, "command": "example.unknown"}));
let (name, id) = ("Odd\u{1b}[31m\nname", format!("example.\r\n{}", "x".repeat(200)));
let odd = json!({"format": "vcactions", "version": 1, "name": "Odd", "actions": [{"name": name, "steps": [{"command": id}]}]});
let r = app.run(LOAD_SET, json!({"data": odd.to_string()})).unwrap();
assert_eq!((r["unknownCommands"][0]["action"].clone(), r["unknownCommands"][0]["command"].clone()), (json!(name), json!(id)));
assert_eq!(app.ui.action_sets.last().unwrap().actions[0].steps[0].0, id, "the set keeps the id");
let shown_id = format!("example.\u{fffd}\u{fffd}{}…", "x".repeat(70));
assert_eq!(
app.ui.status,
format!("Loaded “Odd”: 1 step has an unknown command and was kept: “Odd\u{fffd}[31m\u{fffd}name”, step 1, `{shown_id}`")
);
}
/// An action with an included step whose command is not known does not play at any speed: no
/// step runs, and the error names the step, a `command.batch` step's command by its place in
/// the batch too. An excluded step with an unknown command does not stop it.
#[test]
fn an_action_with_an_unknown_command_does_not_play() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let id = vectorcraft_doc::NodeId(
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(),
);
app.run("select.set", json!({"ids": [id.0]})).unwrap();
let step = || ("object.move".to_string(), json!({"dx": 5, "dy": 0}));
let inner = json!({"commands": [{"command": "object.move", "params": {"dx": 5, "dy": 0}}, {"command": "example.unknown.inner"}]});
app.ui.action_sets = vec![ActionSet::new(
"Test",
vec![
Action::new("Move", vec![step(), ("example.unknown.finish".into(), json!({})), step()]),
Action::new("Batch", vec![step(), ("command.batch".into(), inner)]),
],
)];
let x = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0;
let before = x(&app);
for speed in [PlaybackSpeed::Accelerated, PlaybackSpeed::Step] {
app.ui.action_playback.speed = speed;
assert_eq!(play(&mut app, 0, 0).unwrap_err(), "step 2 of “Move” runs the unknown command `example.unknown.finish`");
assert_eq!(play(&mut app, 0, 1).unwrap_err(), "step 2.2 of “Batch” runs the unknown command `example.unknown.inner`");
assert_eq!(x(&app), before, "no step ran");
assert!(app.ui.action_playing.is_none());
}
app.ui.action_playback.speed = PlaybackSpeed::Accelerated;
app.ui.action_sets[0].actions[0].excluded_steps.insert(1);
play(&mut app, 0, 0).unwrap();
assert!((x(&app) - before - 10.0).abs() < 1e-9, "both included moves ran");
}
/// Where the host can read part of a file, Load Actions… reads one byte past 8 MiB: a larger
/// file is refused without reading all of it, and a smaller one loads.
#[test]
@@ -5086,7 +5266,9 @@ mod tests {
fn a_failing_step_ends_a_paced_playback_with_its_error() {
let (mut app, x) = app_with_three_moves();
app.run(PLAYBACK_OPTIONS, json!({"speed": "step"})).unwrap();
app.ui.action_sets[0].actions[0].steps[1] = ("no.such.command".into(), json!({}));
// A Select Object step that finds no object fails as it plays.
app.ui.action_sets[0].actions[0].steps[1] =
(SELECT_OBJECT_STEP.into(), json!({"note": "missing", "caseSensitive": false, "wholeWord": false}));
play(&mut app, 0, 0).unwrap();
assert_eq!(frame(&mut app, 0.0), None, "the frame after Play draws the first step");
assert_eq!(frame(&mut app, 0.0), None);
+48 -2
View File
@@ -41,7 +41,8 @@ pub(super) const MAX_SET_ACTIONS: usize = 1000;
/// The most steps [`read_set`] reads in one action.
pub(super) const MAX_ACTION_STEPS: usize = 10_000;
/// The most left-out steps that Load Actions… lists for an action set another app saved.
/// The most left-out steps that Load Actions… lists for an action set another app saved, and the
/// most steps with an unknown command it lists for an action set file.
const MAX_SKIPPED: usize = 1000;
/// An action set file: a header, the set's name and its actions.
@@ -488,6 +489,11 @@ fn load_aia(app: &mut VectorcraftApp, params: Value) -> Result<Value, String> {
/// 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<Value, String> {
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<Value>, 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)
}
+42 -24
View File
@@ -125,24 +125,37 @@ fn write(app: &mut VectorcraftApp, store: &Arc<dyn RecoveryStore>, 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 ("<label>…")
// right after this; written right away, it sets none before `then` runs.
let (sets, started_line) = (app.ui.status_sets, format!("{label}…"));
let work = move |_: Writer| job.write();
// A failure is reported here, so the job's result is not an error: that would show as
// "<label> failed: <error>" over this line, or as the round's message when it ran here.
let then = move |app: &mut VectorcraftApp, r: Result<Value, String>| match (r, at) {
(Ok(r), _) => {
done.finish(&mut app.session);
app.status(format!("Saved recovery data for {title}"));
Ok(r)
}
(Err(e), Some(at)) => {
copy_failed(app, &title, &at, &e);
Ok(Value::Null)
}
// A store the host installed has no folder to name: the job's own line.
(Err(e), None) => {
app.status(format!("{saving} failed: {e}"));
log::warn!("vectorcraft: {}", app.ui.status);
Ok(Value::Null)
let then = move |app: &mut VectorcraftApp, r: Result<Value, String>| {
// The result goes to the status bar only while it still shows this job's own line: a
// message set since then (another job's, a restore's with its notes) stays.
let show = app.ui.status_sets == sets || (app.ui.status_sets == sets.wrapping_add(1) && app.ui.status == started_line);
match (r, at) {
(Ok(r), _) => {
done.finish(&mut app.session);
if show {
app.status(format!("Saved recovery data for {title}"));
}
Ok(r)
}
(Err(e), Some(at)) => {
copy_failed(app, &title, &at, &e, show);
Ok(Value::Null)
}
// A store the host installed has no folder to name: the job's own line.
(Err(e), None) => {
let line = format!("{saving} failed: {e}");
log::warn!("vectorcraft: {line}");
if show {
app.status(line);
}
Ok(Value::Null)
}
}
};
// Its own store, not the file writer: this only decides whether it runs on the worker.
@@ -173,18 +186,23 @@ fn failed(app: &mut VectorcraftApp, place: Option<&(String, bool)>, e: &str) {
log::warn!("vectorcraft: {}", app.ui.status);
}
/// [`failed`] for the copy of document `title` that couldn't be written to folder `place`.
fn copy_failed(app: &mut VectorcraftApp, title: &str, place: &(String, bool), e: &str) {
match place {
(folder, true) => app.status(format!(
/// [`failed`] for the copy of document `title` that couldn't be written to folder `place`, shown
/// in the status bar only when `show` holds. The log always has it, and the folder is recorded
/// in [`Timer::named`] either way.
fn copy_failed(app: &mut VectorcraftApp, title: &str, place: &(String, bool), e: &str, show: bool) {
let line = match place {
(folder, true) => format!(
"Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder)"
)),
(folder, false) => app.status(format!(
),
(folder, false) => format!(
"Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder)"
)),
}
),
};
named(app, &place.0);
log::warn!("vectorcraft: {}", app.ui.status);
log::warn!("vectorcraft: {line}");
if show {
app.status(line);
}
}
/// Record that the status text named recovery folder `folder` ([`Timer::named`]).
+5
View File
@@ -574,6 +574,10 @@ pub struct UiState {
#[serde(skip)]
pub touch_drawing_tool: Option<String>,
pub status: String,
/// How many times [`crate::VectorcraftApp::status`] has set `status`, so that work finishing
/// later can tell whether the line it started with is still the latest one. Not saved.
#[serde(skip)]
pub status_sets: u64,
/// The Replace Font request behind the status bar's warning ([`ReplaceFontWarning`]). Not saved.
#[serde(skip)]
pub replace_font_warning: Option<ReplaceFontWarning>,
@@ -1163,6 +1167,7 @@ impl Default for UiState {
group_tool: vectorcraft_tools::TOOL_GROUPS.iter().map(|g| g[0].id.to_string()).collect(),
touch_drawing_tool: None,
status: String::new(),
status_sets: 0,
replace_font_warning: None,
status_notes: None,
palette_open: false,
+82
View File
@@ -414,6 +414,88 @@ fn a_copy_that_fails_in_a_host_store_names_the_document() {
}
}
/// Services that write recovery copies on the background worker.
fn on_the_worker() -> Services {
Services { write_shared: Some(Arc::new(|_: &str, _: &[u8]| Ok(()))), ..Default::default() }
}
/// A copy that finishes after a newer message (a restore's, with its notes) was shown is still
/// written, and the newer message and its notes stay in the status bar.
#[test]
fn a_copy_that_finishes_later_keeps_a_newer_status_and_its_notes() {
let store = Arc::new(MemoryStore::default());
let mut app = app_with(&store, on_the_worker());
recovery::tick(&mut app, 0.0);
assert_eq!(recovery::tick(&mut app, 120.0), 1);
assert_eq!(app.background.jobs.len(), 1, "on the worker");
let newer = "Restored 2 documents with 2 notes: one".to_string();
crate::io::show_with_notes(&mut app, newer.clone(), vec!["one".into(), "two".into()]);
background::wait_all(&mut app);
assert_eq!(copies(&store), 1);
assert!(app.session.doc().unwrap().recovery.is_some());
assert_eq!(app.ui.status, newer);
assert_eq!(app.ui.status_notes, Some((newer, vec!["one".into(), "two".into()])));
}
/// Of two copies started in one round, the first one to finish leaves the second one's
/// "Saving…" line, and the second one reports.
#[test]
fn a_copy_that_finishes_leaves_the_line_of_a_copy_started_after_it() {
let store = Arc::new(MemoryStore::default());
let mut app = app_with(&store, on_the_worker());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
recovery::tick(&mut app, 0.0);
assert_eq!(recovery::tick(&mut app, 120.0), 2);
let second = app.ui.status.clone();
let Some(title) = second.strip_prefix("Saving recovery data for ").and_then(|s| s.strip_suffix('…')) else {
panic!("{second}");
};
let title = title.to_string();
background::wait_until(&mut app, |app| app.background.jobs.len() == 1);
assert_eq!(app.ui.status, second);
background::wait_all(&mut app);
assert_eq!(copies(&store), 2);
assert_eq!(app.ui.status, format!("Saved recovery data for {title}"));
}
/// A copy that fails after a newer message was shown leaves that message in the status bar, in a
/// store the host installed and in a recovery folder; the folder is still recorded.
#[test]
fn a_copy_that_fails_later_keeps_a_newer_status() {
let store: Arc<dyn RecoveryStore> = Arc::new(Refusing(MemoryStore::default()));
let mut app = VectorcraftApp::new(Session::new(), Services { recovery_store: Some(store), ..on_the_worker() });
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
app.session.prefs.autosave_interval = 2;
recovery::tick(&mut app, 0.0);
assert_eq!(recovery::tick(&mut app, 120.0), 1);
app.status("Newer");
background::wait_all(&mut app);
assert_eq!(app.ui.status, "Newer");
let dir = scratch("later");
let folder = dir.join("recovery");
let shown = folder.to_string_lossy().into_owned();
let mut app = app_in_folder(&shown, on_the_worker());
assert_eq!(recovery::tick(&mut app, 120.0), 1);
background::wait_all(&mut app);
// A folder with a file in it takes the copy's place, so the next copy can't be written.
let name = app.session.doc().unwrap().recovery.as_ref().unwrap().file.clone();
let copy = folder.join(format!("{name}.vectorcraft"));
std::fs::remove_file(&copy).unwrap();
std::fs::create_dir(&copy).unwrap();
std::fs::write(copy.join("in-the-way"), b"").unwrap();
app.run("shape.ellipse", json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap();
assert_eq!(recovery::tick(&mut app, 240.0), 1);
app.status("Newer");
background::wait_all(&mut app);
assert_eq!(app.ui.status, "Newer");
assert_eq!(app.recovery.named, std::slice::from_ref(&shown));
drop(app);
let _ = std::fs::remove_dir_all(&dir);
}
/// Issue #367: a tab whose timers were paused (in the background) long enough for another tab to
/// take it for gone and discard its copy writes the copy again on its first heartbeat after it
/// resumes, even with the timer off (the copy was written by hand).
+92 -15
View File
@@ -54,6 +54,42 @@ fn click(app: &mut VectorcraftApp, label: &str) {
frame(app, &ctx, vec![]);
}
/// The names and places of the buttons the task bar draws, from the accessibility tree: an
/// icon-only button is named by its hover text.
fn buttons(app: &mut VectorcraftApp) -> (egui::Context, Vec<(String, Rect)>) {
let ctx = egui::Context::default();
ctx.enable_accesskit();
crate::theme::install_fonts(&ctx);
frame(app, &ctx, vec![]);
let screen = Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 700.0));
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| canvas::show(app, ui));
out.textures_delta.clear();
let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes;
let named = nodes
.iter()
.filter(|(_, n)| n.role() == egui::accesskit::Role::Button)
.filter_map(|(_, n)| {
let b = n.bounds()?;
Some((n.label()?.to_string(), Rect::from_min_max(Pos2::new(b.x0 as f32, b.y0 as f32), Pos2::new(b.x1 as f32, b.y1 as f32))))
})
.collect();
(ctx, named)
}
/// Click the task bar's button named `name` (its label, or an icon-only button's hover text).
fn click_button(app: &mut VectorcraftApp, name: &str) {
let (ctx, named) = buttons(app);
let p = named.iter().find(|(n, _)| n == name).unwrap_or_else(|| panic!("no button named {name:?} in {named:?}")).1.center();
let press = |pressed| Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(app, &ctx, vec![Event::PointerMoved(p)]);
frame(app, &ctx, vec![press(true)]);
frame(app, &ctx, vec![press(false)]);
frame(app, &ctx, vec![]);
}
const TO_CORNER: &str = "Convert Selected Anchor Points to Corner";
const TO_SMOOTH: &str = "Convert Selected Anchor Points to Smooth";
fn select(app: &mut VectorcraftApp, ids: &[u64]) {
app.run("select.set", json!({"ids": ids})).unwrap();
}
@@ -293,27 +329,67 @@ fn direct_selection_of_a_whole_path_offers_its_anchor_actions() {
click(&mut app, "Edit Path");
assert_eq!(app.session.tool_id(), "directSelection");
assert!(app.session.active().unwrap().selection.anchors.is_empty(), "the path is selected whole");
let (_, named) = buttons(&mut app);
let names: Vec<&str> = named.iter().map(|(n, _)| n.as_str()).collect();
for name in ["Connect", TO_CORNER, TO_SMOOTH, "Simplify", "Smooth", "Duplicate"] {
assert!(names.contains(&name), "{name}: {names:?}");
}
for name in ["Edit Path", "Offset Path", "Join", "Remove Anchor Points", "Cut Path"] {
assert!(!names.contains(&name), "{name}: {names:?}");
}
// The convert buttons show their icons alone: the bar draws no convert text, and one "Smooth".
let (_, texts) = bar(&mut app);
for label in ["Connect", "Corner", "Smooth", "Simplify", "Duplicate"] {
assert!(has(&texts, label), "{label}: {texts:?}");
}
for label in ["Edit Path", "Offset Path", "Join", "Remove Anchor Points", "Cut Path"] {
assert!(!has(&texts, label), "{label}: {texts:?}");
}
// Smooth and Corner convert every anchor of the path, one undo step each.
click(&mut app, "Smooth");
assert!(!has(&texts, "Corner") && !texts.iter().any(|(t, _)| t.starts_with("Convert")), "{texts:?}");
assert_eq!(texts.iter().filter(|(t, _)| t == "Smooth").count(), 1, "{texts:?}");
// The convert buttons convert every anchor of the path, one undo step each.
click_button(&mut app, TO_SMOOTH);
let a = anchors(&app, line);
assert_eq!(a[1].kind, vectorcraft_geom::AnchorKind::Smooth, "{a:?}");
assert!(a[1].has_in() && a[1].has_out(), "{a:?}");
click(&mut app, "Corner");
click_button(&mut app, TO_CORNER);
assert!(anchors(&app, line).iter().all(|a| !a.has_in() && !a.has_out()), "{:?}", anchors(&app, line));
app.run("edit.undo", json!({})).unwrap();
assert!(anchors(&app, line)[1].has_in(), "Corner undoes in one step");
// Connect closes the open path; a closed one isn't offered it.
click(&mut app, "Connect");
assert!(matches!(kind(&app, line), NodeKind::Path { path, .. } if path.is_closed()));
let (_, texts) = bar(&mut app);
assert!(!has(&texts, "Connect") && has(&texts, "Corner"), "{texts:?}");
let (_, named) = buttons(&mut app);
assert!(!named.iter().any(|(n, _)| n == "Connect") && named.iter().any(|(n, _)| n == TO_CORNER), "{named:?}");
}
/// With the Direct Selection tool, the bar's Smooth smooths the path's curvature: it opens the
/// Smooth slider, as Object › Path › Smooth… does, and changes nothing until the slider is
/// confirmed (#6784). Simplify opens its slider in the same way.
#[test]
fn direct_selection_smooth_opens_the_smooth_slider() {
for (name, kind) in [("Smooth", crate::dialogs::smooth_slider::KIND), ("Simplify", crate::dialogs::simplify_slider::KIND)] {
let mut app = app();
// A jagged line of 40 anchor points, which Smooth changes at its opening level.
let anchors_in: Vec<_> = (0..40).map(|i| json!({"x": 50 + i * 5, "y": 150 + (i % 2) * 6})).collect();
let line = id(app.run("path.create", json!({"anchors": anchors_in})).unwrap());
app.run("tool.select", json!({"tool": "directSelection"})).unwrap();
select(&mut app, &[line]);
let before = anchors(&app, line);
let undo = app.session.active().unwrap().history.undo.len();
click_button(&mut app, name);
let d = app.ui.dialog.as_ref().unwrap_or_else(|| panic!("{name} opens its slider: {}", app.ui.status));
assert_eq!(d.kind, kind, "{name}");
assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "{name} adds no undo step before it is confirmed");
crate::dialogs::cancel(&mut app);
assert_eq!(anchors(&app, line), before, "{name}: canceling leaves the path as it was");
if name != "Smooth" {
continue;
}
// Confirmed, the slider smooths the path as one undo step, which is not the anchor convert.
click_button(&mut app, name);
crate::dialogs::confirm(&mut app).unwrap();
let history = &app.session.active().unwrap().history.undo;
assert_eq!(history.len(), undo + 1, "one undo step");
assert_ne!(history.last().unwrap().label, "Convert Anchor Points");
assert_ne!(anchors(&app, line), before, "Smooth changes the path's curvature");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(anchors(&app, line), before, "undo restores the path");
}
}
/// Anchors picked out with the Direct Selection tool keep the full set: Remove and Cut need them.
@@ -323,11 +399,12 @@ fn direct_selected_anchors_still_offer_remove_and_cut() {
let line = id(app.run("path.create", json!({"anchors": [{"x": 50, "y": 200}, {"x": 150, "y": 80}, {"x": 250, "y": 200}]})).unwrap());
app.run("tool.select", json!({"tool": "directSelection"})).unwrap();
app.run("select.anchors", json!({"id": line, "anchors": [[0, 1]]})).unwrap();
let (_, texts) = bar(&mut app);
for label in ["Remove Anchor Points", "Connect", "Cut Path", "Corner", "Smooth"] {
assert!(has(&texts, label), "{label}: {texts:?}");
let (_, named) = buttons(&mut app);
let names: Vec<&str> = named.iter().map(|(n, _)| n.as_str()).collect();
for name in ["Remove Anchor Points", "Connect", "Cut Path", TO_CORNER, TO_SMOOTH] {
assert!(names.contains(&name), "{name}: {names:?}");
}
assert!(!has(&texts, "Edit Path"), "{texts:?}");
assert!(!names.contains(&"Edit Path"), "{names:?}");
}
/// Click the task bar's button left of More Options (Lock, or a basic shape's Shape Properties),
File diff suppressed because one or more lines are too long
+5
View File
@@ -221,6 +221,11 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
that leave no art between them, a `Spacing`, `Adjustment` or `FoldAdjustment` other than 0, 1 and 1, art with
strokes, gradients or patterns) and type with an Art brush keep the drawn look.
- **Done (2026-11-14):** Calligraphic paths whose brush filter holds an entry that isn't read or a variable width
keep their drawn look. So do paths followed by samples of the pen (`(…) Xt n XD`) whose Calligraphic brush varies
with the pen; such a path no longer also comes in beside its look, unpainted. Synthetic importer regressions;
scores unchanged.
- **Done (2026-11-12):** Explicitly requested editing data retains a single artboard larger than a capped 14,400 pt PDF page (#6865); the default import and other size mismatches remain unchanged. Synthetic importer regression; scores unchanged.
- **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead
of 14), open speed on large files (#758), live effects, Scatter, Pattern and Bristle brushes on paths, the Art
+17 -6
View File
@@ -3299,7 +3299,13 @@ already walks Image Trace. Short agent entry points:
action and preference. An empty folder and the scratch disk choices `Startup` and `None` save. `ui.actionSet.load {path? | data?}` reads
the file at `path`, else a picked one, or the file's text `data`, adds its set after the other sets and returns
`{set, name, actions}`. A set named like one already in the panel gets the next free number (`Print 2`), and the
other sets and the current set stay as they are. An action set file (`.vcactions`) is JSON:
other sets and the current set stay as they are. A step whose command is not a built-in command, a command of an
installed plug-in, a View menu step or a stop, and such a command of a `command.batch` step, loads as it is, excluded
or not, and `ui.actionSet.save` writes it back. The answer then also has `unknownCommands: [{action, actionIndex, step,
stepIndex, batchStep?, command}]`, the first 1,000 such steps (`step` and `batchStep` count from 1, `actionIndex` and
`stepIndex` from 0, and `batchStep` is the command's place in a `command.batch` step), and `unknownCount`, how many
there are; the status bar names the first and the count, with control characters replaced and long names cut at 80
characters. These are VectorCraft's own rules. An action set file (`.vcactions`) is JSON:
`{"format": "vcactions", "version": 1 to 6, "name", "actions": [{"name", "steps": [{"command", "params", "excluded"?}]}]}`, each step
a command id and its params as `command.batch` takes them, or a stop: the command `action.stop` with
`{message, allowContinue}`. A Select Object step is the command `select.byNote` with `{note, caseSensitive,
@@ -3379,8 +3385,12 @@ already walks Image Trace. Short agent entry points:
made active, or that document was closed), the playback ends with a status and the rest do not play; a step that
makes another document active itself does not end it. A name that no
action has, or that more than one action in the sets looked in has, or whose every step is excluded, is an error, and
then nothing plays. So is an action played while another is still playing at Step by Step or Pause For. A
`command.batch` step, which an action played or a panel operation run as one batch records, plays as its own steps,
then nothing plays. So is an action played while another is still playing at Step by Step or Pause For. So is an
action with an included step whose command is not a built-in command, a command of an installed plug-in, a View menu
step or a stop at the time it plays, at every speed: the error names the first such step, as in step 2 of “Print”
runs the unknown command `example.finish`, and a command of a `command.batch` step by its place in the batch, as in
step 2.3. An excluded step is not checked.
A `command.batch` step, which an action played or a panel operation run as one batch records, plays as its own steps,
here and in Batch…. An action with an included stop plays up to the stop at any speed and then waits, with its
message box open, until `ui.action.continue` or `ui.action.stop`. At Accelerated the steps between two stops are one
undoable batch each. The result is then `{results, pending, atStop?}`, with `atStop: true` when the action already
@@ -5381,9 +5391,10 @@ keeps the segment's direction when the smoothed path there turns back by 90° or
path, the curve keeps the direction of the part outside the drag. `object.path.smooth` and Simplify keep each end's
direction.
In the desktop app, Object › Path › Smooth and the canvas context menu's Smooth… (listed while the selection holds a
path) open the slider (`ui.menuDialog {command: "object.path.smooth"}`, dialog `smoothSlider` with field `level`) at
the Auto-Smooth level, and the slider previews the result until `ui.dialog.confirm`; `ui.smoothAuto` moves the open
In the desktop app, Object › Path › Smooth, the canvas context menu's Smooth… (listed while the selection holds a
path) and the Contextual Task Bar's Smooth (with a path selected whole with the Direct Selection tool) open the
slider (`ui.menuDialog {command: "object.path.smooth"}`, dialog `smoothSlider` with field `level`) at the
Auto-Smooth level, and the slider previews the result until `ui.dialog.confirm`; `ui.smoothAuto` moves the open
slider back to the Auto-Smooth level.
## Editing a path with the Curvature tool
+5 -2
View File
@@ -627,6 +627,7 @@ since 2026-10-01.
- **`.ai` files keep an image's Warp live:** an embedded image whose appearance is one Warp effect, with no opacity mask, is saved to the `.ai` editing data with an art style of that Warp: its bent pieces are its drawn look, and the image itself follows on `%_` lines, as for other styled objects. Opening an `.ai` file reads such an image as the image with its Warp live, its pixels, placement, name and opacity kept, when the live warp draws as the file's drawn look and the import has a comparison left; otherwise the drawn look stays, with the drawn-look note. The Warp effect applies to bitmap images ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html)), and native files keep live effects ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/supported-file-formats-in-illustrator.html)). Images with an opacity mask, and images with a Warp and another effect or with another geometry effect, are saved and opened as their drawn art. Existing `.ai` files with an embedded image on `%_` lines open differently: before, that image also came in beside its drawn look. Which images keep the Warp and the comparison are VectorCraft's own rules (eps `ai_editing::tests::an_image_with_a_warp_reads_back_with_its_warp_live`, `an_image_whose_warp_draws_otherwise_than_its_look_keeps_its_look`, `an_image_on_hidden_lines_does_not_come_in_beside_its_look`, `other_warped_images_are_written_as_their_drawn_art`, `import::ai::read::ops::tests::an_image_takes_a_style_of_one_warp_alone`). Scores and hours unchanged.
- **`.ai` files keep a linked image's Warp live and its link:** a linked image whose appearance is one Warp effect, with no opacity mask, is saved to the `.ai` editing data with an art style of that Warp, as an embedded one is: its bent pieces are its drawn look, and the image itself follows on `%_` lines as a placed file with its path, its folder relative to the document's and its preview. Before, it was saved as its drawn art and its live Warp was lost. Opening the file reads it as the linked image with its Warp live, its link, pixels, placement, name, opacity, hide and lock kept, under the same drawn-look comparison; a hidden image with a Warp, embedded or linked, is now compared with its look drawn shown, and keeps its Warp too. A linked image with a Warp in an opacity mask's art keeps its Warp and is embedded, with the existing warning for linked images in masks. Linked files stay connected to their source and track it by relative path ([the help page](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/manage-linked-and-embedded-files/links-panel-overview.html)); the placed-file form is the one the `.ai` reader reads (`crates/eps/src/import/ai/read/art.rs` `end_place`). eps `ai_editing::tests::a_linked_image_with_a_warp_reads_back_linked_with_its_warp_live`, `a_large_linked_image_with_a_warp_reads_back_from_its_preview`, `a_hidden_or_locked_image_with_a_warp_keeps_its_state`, `a_linked_image_with_a_warp_in_a_mask_is_embedded_with_its_warp_live`. Scores and hours unchanged.
- **EPS writes a repeated image's samples once:** an image drawn more than once in an EPS file (the pieces of an envelope or a Warp effect, copies of one image, or one image in a symbol's instances) has its compressed samples written once in the setup, and each placement reads them from there: a reusable stream rewound for each placement at Language Level 3 ([PostScript Language Reference, 3rd ed., §3.13](https://www.adobe.com/jp/print/postscript/pdfs/PLRM.pdf)), and an array of strings of at most 49,152 bytes fed to the image by a procedure at Level 2. An image drawn once is written where it is drawn, as before. The 256 MB limit on the page counts the samples once, so an envelope of a large photo no longer loses pieces to it. A colorized image is reused per ink (eps `tests::an_image_placed_twice_writes_its_samples_once_at_both_levels`, `tests::an_envelopes_image_pieces_draw_one_copy_of_its_samples`, `tests::each_ink_of_a_colorized_image_placed_twice_is_written_once`, `tests::samples_longer_than_a_string_are_reused_at_both_levels`, `tests::the_page_limit_counts_a_repeated_images_samples_once`). Scores and hours unchanged.
- **`.ai` files write a warped image's pieces as the parts they show:** in the `.ai` editing data and in legacy `.ai` files, an embedded image cut to a clipping path (in the editing data, also an image under an opacity mask that clips) is written with all of its pixels the first time, and every later copy of the same pixels is written as the rectangle of them that the path or mask shows, 2 pixels more on each side, placed where it lies in the image. The pieces of a Warp effect, an envelope or their expanded art, 200 copies of the image for 10 × 10 cells before, now hold about 7 times the image's pixels in all for a 64 × 64 image. A copy whose opacity mask is left out, nested too deep or past the object limit, keeps all of its pixels. Each image's pixels are read once for the pieces that follow one another. An image cut to a rectangle of half its pixels or more, and the first copy written, keep all of their pixels. The `XI` operator's arguments give an image's location and size ([AI file-format specification, §8.1](https://web.archive.org/web/20050126105313/https://partners.adobe.com/public/developer/en/illustrator/sdk/AI7FileFormat.pdf)); the 2-pixel margin and the half-the-pixels limit are VectorCraft's own rules (eps `ai_editing::tests::the_pieces_of_a_warped_image_are_written_as_the_parts_they_show`, `masked_pieces_of_a_warped_image_are_written_as_the_parts_they_show`, `an_image_with_a_live_warp_writes_its_pixels_whole_once`, `a_clipped_image_written_first_keeps_all_of_its_pixels`, `a_masked_copy_whose_mask_is_left_out_keeps_all_of_its_pixels`, `a_masked_copy_nested_too_deep_for_its_mask_keeps_all_of_its_pixels`, `legacy_ai::tests::the_pieces_of_a_warped_image_are_written_as_the_parts_they_show`). Scores and hours unchanged.
- **EPS writes a repeated freeform gradient's image once:** a freeform gradient whose image samples the same on more than one fill (copies of a shape moved with their points, or one shape in a symbol's instances) has its samples written once in the setup, as a repeated image's are, and a fill whose image differs (other colors, another size) gets its own. A freeform fill drawn once is written where it is drawn, as before, and a fill whose placement cannot be drawn, or that arrives when the page is already at the 256 MB limit, is no longer sampled first (eps `tests::a_freeform_gradients_image_drawn_twice_writes_its_samples_once`, `tests::freeform_gradients_sampled_differently_dont_share_an_image`, `tests::a_field_taken_back_at_the_page_limit_leaves_no_image_behind`). Scores and hours unchanged.
- **PDF writes a warped image once:** with downsampling on, the pieces of an image bent by a Warp effect are resampled for the widest and the highest size any piece places the image at, so every piece paints one image XObject (a colorized image: one colorized image and its soft mask), instead of one image for each size a piece is placed at. Another placement of the same image is still resampled for its own size. Without downsampling, a colorized image and a CMYK image with a grayscale image mask on its grid are decoded and encoded once for all the sizes they are placed at (pdf `tests::compression::a_warped_image_is_written_once_for_all_its_pieces`, `tests::colorize::a_colorized_warped_image_is_written_once_for_all_its_pieces`, `export::images::tests::colorized_and_grid_masked_images_are_made_once_for_every_size_without_downsampling`). Scores and hours unchanged.
- **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged.
@@ -771,6 +772,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Batch…: Override Save and Override Export name each file with the extension of the format the step writes, however the step spells the format (`.svg` and `SVG` give `.svg`, `png8` gives `.png`), in `action.batch`.
- Batch…: Override Export writes the files of an Export for Screens or Export Assets step into a folder for each source file or data set inside the destination folder, under the names the export gives them (`action.batch`). The folder and file names drop trailing periods and spaces (`a..svg` gives `a`). When two source files would get the same name that way (compared without case), or a later file's folder or file would take the name of an output an earlier file wrote, of a source file in the destination folder or, for data sets, of a file already there, Batch adds `-2`, `-3`… to the later one's name.
- Actions panel menu: Save Actions… writes the current action set with all its actions to a file, and Load Actions… adds the set from such a file to the panel ([Manage a set of actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/manage-a-set-of-actions.html); `ui.actionSet.save`, `ui.actionSet.load`). The file is VectorCraft's own action set file (`.vcactions`); a loaded set named like one in the panel gets the next free number.
- Actions panel menu: Load Actions… keeps a step whose command this build or its installed plug-ins do not have, and reports each one when the set loads: the `ui.actionSet.load` answer lists the action, step and command id (`unknownCommands`, `unknownCount`), and the status bar names the first and the count. An action with such a step included does not play, and the error names the step and its id before any step runs; an excluded one does not stop it. These are VectorCraft's own rules. Tests `panels::actions::tests::{load_actions_reports_steps_with_unknown_commands, load_actions_bounds_what_it_reports_of_unknown_commands, an_action_with_an_unknown_command_does_not_play}`.
- Actions panel menu: Save Actions… does not write a set with a step that sets a folder preference (the Fonts, Plug-ins, Templates and Recovery folders and the two scratch disks) to a folder, and names the action and the preference instead; an empty folder and the scratch disk choices Startup and None save. Export Preferences leaves out both scratch disks, and `ui.inspect` under automation read roots leaves such a folder outside them out of recorded steps. These are VectorCraft's own rules (`ui.actionSet.save`, `prefs.export`).
- Actions panel menu: Load Actions… also reads action sets saved as `.aia` files ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `actionSet.read`, `ui.actionSet.load`). VectorCraft's own rules: the set comes in with all its actions. A step with a label and no recorded values comes in as the one menu command with the step's label, when that command is directly in a top menu, is what its menu bar item runs with no values and opens no dialog. A step that is off in the file stays excluded from playback. The other steps, recorded menu items included, are left out, and the status bar says how many.
- Actions panel: the panel menu's Action Options… renames the selected action and keeps its steps ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html): the menu item opens a dialog that renames the action or gives it a function key and color; `ui.action.options`). It also sets the action's function key and color.
@@ -873,7 +875,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- The help page describes the contextual task bar as one horizontal row (<https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/workspace-overview.html>) and gives no rule for a canvas narrower than that row. VectorCraft's own rule: the bar stays whole on the canvas. When its actions don't fit in one row, they wrap, in order, onto rows under the row with the handle, the chips and More Options; an action wider than any row, or one that would need a row past the canvas's height, is listed in More Options instead; when the chips don't fit beside the handle and More Options, they take rows after the actions; with no row to spare for the font controls, the bar leaves them out; a canvas too small for the handle and More Options shows no bar. Evidence: `a_task_bars_actions_wrap_on_a_narrow_canvas` and `a_task_bar_action_wider_than_the_canvas_goes_in_more_options` in `crates/ui-egui/src/tests/taskbar.rs`.
- With a live rectangle, rounded rectangle, ellipse or polygon selected with the Selection tool, the contextual task bar shows a Shape Properties button in place of Lock. It opens a popup with the shape's rows from the Transform panel (corner radius, pie angles, or sides, angle, corner and radius), which edit it through `object.setLiveShape`. The overview describes this: when a shape drawn with a shape tool (Rectangle, Star) is selected, the bar offers to change its properties where Lock Object would be ([Contextual Task Bar overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html)). Object › Lock and ⌘2 still lock the shape. Not done: stars, which the app draws as plain paths. Evidence: `a_basic_shape_shows_its_properties_in_place_of_lock`, `the_shape_properties_popup_edits_the_shape` and `other_selections_keep_lock` in `crates/ui-egui/src/tests/taskbar.rs`.
- The contextual task bar has a Stroke chip beside the Fill chip, drawn with the paint the Stroke proxy shows (a "?" when the selected objects' strokes differ, as on the Fill chip); a click brings the Stroke proxy forward and shows the Swatches panel wherever it is docked, floating or closed, as a click on the Fill chip does for the Fill proxy. The reference app's bar has Fill and Stroke controls for changing an object's colors ([Contextual Task Bar overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html)). Evidence: `the_task_bar_shows_a_stroke_chip_beside_fill` and `the_task_bar_chips_follow_the_proxies_and_show_swatches_anywhere` in `crates/ui-egui/src/canvas/tests.rs`.
- With anchor points selected with Direct Selection, the contextual task bar offers Remove Anchor Points, Connect (`path.join`), Cut Path, Corner and Smooth (`path.convertAnchors` with `to` set to `corner` or `smooth`), the commands of the Control bar's anchor buttons. As in Illustrator, whose bar has items that delete the selected anchors, join selected end points, split the path at the selected anchors, and make the selected anchors corner or smooth points ([Contextual Task Bar overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html)). Not done: Simplify, which the bar offers only while no anchors are selected, and the bar's Smooth item that adjusts a path's curvature, a command the app doesn't have (Object › Path › Smooth).
- With anchor points selected with Direct Selection, the contextual task bar offers Remove Anchor Points, Connect (`path.join`), Cut Path, and Convert Selected Anchor Points to Corner and to Smooth (`path.convertAnchors` with `to` set to `corner` or `smooth`), the commands of the Control bar's anchor buttons; the two convert buttons show their icons alone, named by their tool tips as on the Control bar (VectorCraft's own choice). The overview lists items that delete the selected anchors, join selected end points, split the path at the selected anchors, and make the selected anchors corner or smooth points ([Contextual Task Bar overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html)). With a path selected whole with Direct Selection, the bar also offers Simplify and Smooth, which open the Simplify and Smooth sliders as Object › Path › Simplify… and Smooth… do; the same overview names Smooth as adjusting the path's curvature (#6784). VectorCraft's own rule: while anchors are picked out, the bar offers the anchor items without Simplify and Smooth. Evidence: `direct_selection_smooth_opens_the_smooth_slider` in `crates/ui-egui/src/tests/taskbar.rs`.
- Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it.
- Screen readers get a name for the Tools panel's tool buttons, flyout rows, floating strips and tear-off bars, and for the icon buttons, text buttons, toggles, checkboxes, radio buttons, tabs, chips, sliders, angle dials and combo boxes that panels and dialogs draw with `crates/ui-egui/src/widgets.rs`: the text, label or tool tip each one shows, or a name of its own where it shows none (the angle dial, the spinner, the preset arrow, the color wheel and the Reference Point's squares). The tool buttons, toggles, checkboxes, radio buttons, tabs and the Reference Point's nine squares also report whether they are on, an icon button that is lit, such as the chosen Align To button, reports that it is pressed, and the angle dial reports its angle. The status bar's First, Previous, Next and Last Artboard buttons have tool tips.
- Tab does not stop on the regions that only the pointer uses: the custom title bar's background and the window's resize zones, the band at the top of a dialog that moves it, the Tools panel's grip, a flyout's and a floating strip's tear-off bar, the isolation bar's background, and the rulers and the box where they meet.
@@ -1591,6 +1593,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Object › Expand Appearance keeps each stroke of a path a stroke (weight, caps, joins, dashes, arrowheads, alignment and width profile) on a copy of the path with the geometry effects baked in, and Object › Expand… is dimmed, with a tooltip, while the selection has appearance attributes, so a stroked line with Zig Zag becomes a stroked zig-zag line and only Expand then outlines it, the two steps the [Expand objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html) help page describes (#3089). Brushed strokes still become their brush art; `object.expand` still bakes and outlines in one step for agents.
- **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks.
- **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document view state saved in native files (#1968); a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks.
The geometry effects of a clipping path (Roughen, Transform and the other effects that reshape a path), of its fills and strokes, and of the members of a group used as one, shape the area it clips to on the canvas and in raster export, as in SVG and PDF export: a roughened clipping path clips by its roughened outline. They apply before the geometry effects of the clip group or of a group around it, on the canvas and in every export, and type used as a clipping path keeps them when a group's effect outlines it. Raster effects such as Gaussian Blur leave that area as it is. `effects::tests::clipfx` and the renderer's `tests::clip` cover it.
- **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. SVG export writes a knockout group of opaque art plainly, without masks, so shapes meeting edge to edge leave no seam; translucent, blended, masked or brushed art knocks out through masks. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel. Flattening for files without transparency (PDF/X-1a, PDF/X-3) in a CMYK document keeps the inks where it rasterizes, as under an image with see-through pixels: the images it makes hold ink amounts, not screen colours the file would separate again, so a C100 M6 Y41 K3 panel stays C100 M6 Y41 K3 there (#2160, `engine/src/cmd/fileio/tests_pdfx.rs`).
On the canvas, the renderer no longer draws the picture of the art below back as an image each time it copies the backdrop of a non-isolated group that blends: it keeps that picture and draws the art that follows over it. At 2880 × 1800, 50 translucent groups with Multiply members take 65 ms a frame instead of 299 ms on 8 threads (`vectorcraft-cli bench`), and the frames have the same pixels.
Type inside a group, layer, symbol instance, blend or repeat keeps its opacity, blend mode, opacity mask, Opacity & Mask Define Knockout Shape, own effects, perspective and visibility when the effects on the container outline it. Hidden type with a geometry effect of its own is left out of exports.
@@ -1651,7 +1654,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604).
Preferences › File Handling › Data Recovery › Folder has a Choose… button on the desktop that picks the folder Data Recovery keeps its copies in ([Illustrator reference, Set Data Recovery preferences](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf)); a picked folder that Data Recovery cannot create its folders in or list is refused with a status message, and the field keeps its value. OK applies the same check to a folder typed into the field (VectorCraft's own rule): when it fails, the dialog stays open, the status bar says why and no preference changes; an empty field (the default folder) and the folder already saved are not checked. Preferences › Plug-ins & Scratch Disks is where additional plug-ins folders are set ([Illustrator preferences help](https://helpx.adobe.com/illustrator/desktop/get-started/preferences-and-settings/set-app-preferences.html)); its Additional Plug-ins Folder has a Choose… button on the desktop as well, VectorCraft's own, matching Data Recovery's, and VectorCraft's own rule refuses a picked or typed folder whose contents can't be listed. Additional Fonts Folder and Templates Folder stay typed only.
`prefs.set` applies the dialog's check to `recoveryFolder` and `pluginsFolder` for every caller (VectorCraft's own rule): a folder that fails returns an error and no preference changes; an empty value and the folder already saved are not checked.
When a Data Recovery copy can't be written, the status bar and the log show the error (with the document's title when one document's copy fails), the folder copies go to, whether that is the default recovery folder or the folder set in Preferences, and the preference that picks another folder (`recoveryFolder`; VectorCraft's own message).
When a Data Recovery copy can't be written, the status bar and the log show the error (with the document's title when one document's copy fails), the folder copies go to, whether that is the default recovery folder or the folder set in Preferences, and the preference that picks another folder (`recoveryFolder`; VectorCraft's own message). A copy written in the background reports its result in the status bar only while the status bar still shows that copy's own "Saving recovery data for …" line, so a message shown since then, such as Restore's count with its notes or another copy's line, stays; a failure is still written to the log (VectorCraft's own rule).
After Restore in the dialog Data Recovery shows at launch, the status bar keeps the restored documents' notes (VectorCraft's own rule): it shows the count followed by the first note, and hovering it lists each document's lines about its linked files and its warnings, named with the document's title when more than one was restored. The questions about the documents' missing or modified linked files come one document at a time, with that document made the current one, and a copy that can't be restored shows its error while the hover keeps the count and the restored documents' notes (`tests/recovery.rs`).
A name typed in the desktop save panel without the file type's extension gets it, and when a file of that name exists, which the panel did not ask about, Save, Save As, Save a Copy, Save as Template, the exports and every other write through a save panel ask before replacing it (dialog `replaceFile`; Cancel writes nothing).
A name returned by the desktop save panel with the extension of a format the save does not write has it replaced with the saved format's: Save As of a new document saves `card.ait` and `card.png` as `card.vectorcraft`, and Save as Template saves `card.pdf` as `card.vctemplate`. An extension that names no format stays part of the name (`notes.v2` is saved as `notes.v2.vectorcraft`).