Merge main into fix/batch-override-export-for-screens-folder-r

This commit is contained in:
dotCooCoo
2026-10-31 20:07:09 -07:00
70 changed files with 3961 additions and 122 deletions
+3 -1
View File
@@ -9,7 +9,7 @@
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Progress log row for the Arrange Tools add-on's Sort Objects; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Progress log row for the Path Precision Tools add-on's Expand Dashes into Paths; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
@@ -129,6 +129,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
## Progress log
- 2026-11-01: Expand Dashes into Paths joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): each selected path's dashed stroke becomes separate open paths, one per dash, laid out exactly as the stroke draws them (pattern, offset, Align Dashes to Corners and Path Ends), each keeping the stroke with the dashes off, so the gaps are real gaps for cutters and plotters, as one undo step (`addon.pathtools.expandDashes`, #4922).
- 2026-11-01: Randomize Opacity joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): each selected object gets its own opacity at random between a minimum and a maximum percent, with a seed that repeats the result, as one undo step (`addon.arrange.randomizeOpacity`, #4925).
- 2026-11-01: Sort Objects joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): the selected objects are sorted by width, height, area, position, opacity or stacking order (small to large, large to small or at random) and take another of those in that order, trading their values or stepping them evenly, as one undo step (`addon.arrange.sort`, #4917).
- 2026-11-01: New Symbols from Layers joins the Document Tools add-on (Object › Plug-ins › Document Tools): one symbol of each top-level layer's art (the layers highlighted in the Layers panel when two or more are, else every layer), named after the layer, made as New Symbol makes one; Convert Layers to Symbols also puts an instance of each in place of its layer's art. Empty layers are skipped and reported; one undo step. A VectorCraft addition: headline numbers unchanged.
@@ -210,6 +211,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap
| Date | Change | Summary |
|---|---|---|
| 2026-11-01 | minor | Progress log row for the Path Precision Tools add-on's Expand Dashes into Paths (#4922); headline numbers unchanged. |
| 2026-11-01 | minor | Progress log row for the Arrange Tools add-on's Randomize Opacity (#4925); headline numbers unchanged. |
| 2026-11-01 | minor | Progress log row for the Arrange Tools add-on's Sort Objects (#4917); headline numbers unchanged. |
| 2026-11-01 | minor | Progress log row for the Document Tools add-on's New Symbols from Layers (#4927); headline numbers unchanged. |
+22 -1
View File
@@ -20,7 +20,7 @@ use std::sync::Arc;
use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint};
use vectorcraft_geom::kurbo::ParamCurveArclen;
use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, SubPath, Vec2};
use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2};
use crate::appearance::{Appearance, AppearanceItem, Dash, StrokeLayer, WidthProfile};
use crate::live::{BlendOrientation, BlendSpacing, BlendSpec};
@@ -1029,6 +1029,27 @@ impl Rail {
}
}
/// A box round everything a blend of `keys` can paint once [`Rail::place`] puts its keys and steps
/// on its spine, turned to it with Align to Path: the spine's box grown by three times the farthest
/// any key paints from its centre. A step's shape lies within that distance of the point on the
/// spine it is moved to, and so does its own centre, which it turns about. `None` when nothing
/// moves.
pub(crate) fn rail_reach(keys: &[Arc<Node>], spec: &BlendSpec, symbols: Option<&crate::SymbolBoxes>) -> Option<Rect> {
if spec.spine.is_none() && spec.orientation != BlendOrientation::AlignToPath {
return None;
}
let spine = blend_spine(keys, spec)?.0.bounds()?;
let radius = keys
.iter()
.filter_map(|k| {
let (c, b) = (center_of(k), k.visual(symbols)?);
let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x0, b.y1), Point::new(b.x1, b.y1)];
Some(corners.into_iter().map(|q| (q - c).hypot()).fold(0.0, f64::max))
})
.fold(0.0, f64::max);
Some(spine.inflate(3.0 * radius, 3.0 * radius))
}
// =====================================================================================
// Blend evaluation
// =====================================================================================
+161 -3
View File
@@ -1,7 +1,9 @@
//! Hit testing over the document tree.
use std::cell::RefCell;
use std::collections::HashSet;
use std::sync::OnceLock;
use std::rc::Rc;
use std::sync::{Arc, OnceLock};
use vectorcraft_geom::hit::{fill_contains, stroke_contains};
use vectorcraft_geom::{BezPath, FillRule, Point, Rect};
@@ -99,6 +101,128 @@ pub(crate) fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)>
IMAGE_PAINT.get().map_or_else(Vec::new, |f| f(n))
}
/// One level of evaluation of a live object in a document, as the canvas draws it: type in an
/// envelope as its distorted glyph outlines, symbol instances as their art. Effects are evaluated
/// above this crate, so the engine registers one with [`set_live_art`]. It reads the object, the
/// document's symbols and patterns, and what [`LiveInputs`] stands for, and nothing else.
pub type LiveArt = fn(Option<&Document>, &Node) -> Vec<Node>;
/// A number that changes whenever something outside the document that [`LiveArt`] reads changes,
/// such as the installed fonts or the plug-ins.
pub type LiveInputs = fn() -> u64;
static LIVE_ART: OnceLock<(LiveArt, LiveInputs)> = OnceLock::new();
/// Register how a live object's art is evaluated for clicks, and the number that changes with what
/// else it reads (the first registration stays).
pub fn set_live_art(f: LiveArt, inputs: LiveInputs) {
// Already set: the same engine, registered by another session.
let _ = LIVE_ART.set((f, inputs));
}
/// The registered [`LiveInputs`] now, 0 while nothing is registered.
fn live_inputs() -> u64 {
LIVE_ART.get().map_or(0, |(_, inputs)| inputs())
}
/// A live object evaluated by [`live_art`] on this thread: the object, its document's symbols (by
/// name and art) and patterns and the [`LiveInputs`] as they were, the art and its [`art_size`].
struct EvaluatedLive {
node: Node,
symbols: Vec<(String, Arc<Node>)>,
patterns: Vec<crate::PatternDef>,
inputs: u64,
art: Rc<Vec<Node>>,
size: usize,
}
impl EvaluatedLive {
/// Is this the evaluation of `n` in `doc`, with inputs `inputs`, as they are now?
fn is_of(&self, doc: &Document, n: &Node, inputs: u64) -> bool {
self.inputs == inputs
&& self.symbols.len() == doc.symbols.len()
&& self.symbols.iter().zip(&doc.symbols).all(|((name, art), s)| *name == s.name && Arc::ptr_eq(art, &s.art))
&& self.node == *n
&& self.patterns == doc.patterns
}
}
/// How many evaluations [`live_art`] keeps on each thread: the pointer moving in a blend's or an
/// envelope's stroke band hits it again and again, and an envelope can hold a blend.
const LIVE_KEPT: usize = 4;
/// Most [`art_size`] the evaluations [`live_art`] keeps on one thread add up to. Art larger than
/// this is not kept.
const LIVE_KEPT_SIZE: usize = 250_000;
/// The size of `art` for [`LIVE_KEPT_SIZE`]: its objects and the anchors of their paths.
fn art_size(art: &[Node]) -> usize {
let mut size = 0usize;
for n in art {
n.walk(&mut |c| size = size.saturating_add(1 + c.path_data().map_or(0, |p| p.anchor_count())));
}
size
}
/// Does `n`, or the art of a symbol or a pattern of `doc`, hold a placed document? Its art changes
/// when the file is read, so [`live_art`] does not keep an evaluation of `n`.
fn holds_placed(doc: &Document, n: &Node) -> bool {
let mut placed = false;
let mut find = |c: &Node| placed |= matches!(c.kind, NodeKind::PlacedDocument(_));
n.walk(&mut find);
doc.symbols.iter().for_each(|s| s.art.walk(&mut find));
doc.patterns.iter().flat_map(|p| &p.art).for_each(|a| a.walk(&mut find));
placed
}
thread_local! {
/// The evaluations [`live_art`] keeps, the latest first.
static EVALUATED: RefCell<Vec<EvaluatedLive>> = const { RefCell::new(Vec::new()) };
}
/// The art live object `n` of `doc` draws ([`set_live_art`]); [`crate::live::expand_live`] while
/// nothing is registered. The art is evaluated again when `n`, a symbol of `doc`, a pattern of
/// `doc` or the [`LiveInputs`] have changed since, and on every click while it or the document's
/// symbols or patterns hold a placed document ([`holds_placed`]).
fn live_art(doc: &Document, n: &Node) -> Rc<Vec<Node>> {
let inputs = live_inputs();
let kept = EVALUATED.with_borrow_mut(|kept| {
let i = kept.iter().position(|e| e.is_of(doc, n, inputs))?;
let e = kept.remove(i);
let art = e.art.clone();
kept.insert(0, e);
Some(art)
});
if let Some(art) = kept {
return art;
}
#[cfg(test)]
LIVE_EVALUATED.with(|c| c.set(c.get() + 1));
let art = Rc::new(LIVE_ART.get().map_or_else(|| crate::live::expand_live(n), |(f, _)| f(Some(doc), n)));
let size = art_size(&art);
if size > LIVE_KEPT_SIZE || holds_placed(doc, n) {
return art;
}
let symbols = doc.symbols.iter().map(|s| (s.name.clone(), s.art.clone())).collect();
let e = EvaluatedLive { node: n.clone(), symbols, patterns: doc.patterns.clone(), inputs, art: art.clone(), size };
EVALUATED.with_borrow_mut(|kept| {
kept.insert(0, e);
kept.truncate(LIVE_KEPT);
let mut total = 0usize;
kept.retain(|e| {
total = total.saturating_add(e.size);
total <= LIVE_KEPT_SIZE
});
});
art
}
#[cfg(test)]
thread_local! {
/// How many live objects [`live_art`] evaluated on this thread.
static LIVE_EVALUATED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Topmost editable object under `p`.
pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
hit_test_skipping(doc, p, opt, &|_| false, true)
@@ -565,7 +689,15 @@ fn hit_stroke(n: &Node, bp: &vectorcraft_geom::BezPath, rule: vectorcraft_geom::
stroke_contains(bp, 0.0, opt.tol, p).then_some(HitKind::Outline)
}
#[cfg(test)]
thread_local! {
/// How many objects [`hit_leaf`] tested on this thread.
static LEAVES_TESTED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option<HitKind> {
#[cfg(test)]
LEAVES_TESTED.with(|c| c.set(c.get() + 1));
match &n.kind {
NodeKind::Path { path, rule, .. } => {
let bp = path.to_bezpath();
@@ -612,9 +744,18 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio
AppearanceItem::Fill(_) => (!opt.path_only && fill_contains(bp, *rule, p)).then_some(HitKind::Fill),
})
}
NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } => {
n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds)
}
// A blend or an envelope is hit within its bounds, and outside them where its evaluated
// art paints: a blend's keys and steps on a spine can reach past the keys and the spine,
// and the strokes of an envelope's distorted contents past its bounds.
NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
if n.geometric_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) {
return Some(HitKind::Bounds);
}
live_art(symbols.document(), n).iter().find_map(|g| hit_any(g, p, opt, symbols))
}
NodeKind::Repeat(r) => r.expand(Some(symbols.document())).iter().find_map(|g| hit_any(g, p, opt, symbols)),
NodeKind::Mesh(m) => {
let bp = m.outline().to_bezpath();
@@ -651,8 +792,25 @@ fn on_type_path(n: &Node, t: &crate::TextObject, p: Point, tol: f64) -> bool {
/// Hit anywhere in an evaluated subtree (groups recurse; leaves use [`hit_leaf`]).
fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option<HitKind> {
// Hidden art does not hit.
if !n.visible {
return None;
}
// Nor art whose reach does not hold `p`, as in [`may_hit`]: a blend's steps far from `p` are
// passed by without being measured.
if !edits_contents(n) && n.extras().shaper.is_none() && n.reach_bounds_with(symbols).is_some_and(|b| !b.inflate(opt.tol, opt.tol).contains(p)) {
return None;
}
match &n.kind {
NodeKind::Group { children, .. } => children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols)),
// A clip group only hits inside its clipping path.
NodeKind::Group { children, clip } => {
if *clip && children.first().is_some_and(|c| !clip_contains(c, p)) {
return None;
}
children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols))
}
// An intertwine group hits by its members, as on the canvas.
NodeKind::Intertwine { children } => children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols)),
_ => hit_leaf(n, p, opt, symbols),
}
}
+145
View File
@@ -429,6 +429,151 @@ fn a_marquee_meets_blends_and_envelopes_by_their_box() {
}
}
/// A click just outside a blend's bounds, in the reach of its keys' strokes, tests only the steps
/// whose own reach holds the point, not every step of the evaluated blend.
#[test]
fn a_click_in_a_blends_stroke_band_tests_only_the_steps_that_reach_it() {
let mut d = Document::new(1000.0, 1000.0);
let l = d.layers[0].id;
// 10 pt circles with 4 pt strokes, y 295 to 305, from x 10 to x 890: 200 steps about 4.3 pt apart.
let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0);
let circle = |id, x: f64| Arc::new(Node::path(id, shapes::ellipse(Rect::new(x, 295.0, x + 10.0, 305.0)), stroked.clone()));
let blend = d.alloc_id();
let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)];
let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() };
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap();
let count = |p: Point| {
LEAVES_TESTED.with(|c| c.set(0));
let h = hit_test(&d, p, HitOptions::default()).map(|h| h.leaf);
(h, LEAVES_TESTED.with(std::cell::Cell::get))
};
// On the steps' strokes halfway between the keys, 4 pt above the bounds: a few steps reach it.
let (h, tested) = count(Point::new(450.0, 291.0));
assert_eq!(h, Some(blend));
assert!(tested < 20, "{tested} objects tested");
// In the corner of the reach, outside every stroke: nothing hits.
let (h, tested) = count(Point::new(6.0, 291.0));
assert_eq!(h, None);
assert!(tested < 20, "{tested} objects tested");
}
/// An intertwine group in a live object's evaluated art (a blend key a spine moves) hits by its
/// members.
#[test]
fn evaluated_art_hits_an_intertwine_group_by_its_members() {
let d = Document::new(200.0, 200.0);
let square = Arc::new(Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art()));
let group = Node::new(NodeId(8), NodeKind::Intertwine { children: vec![square] });
let symbols = d.symbol_boxes();
assert_eq!(hit_any(&group, Point::new(10.0, 10.0), HitOptions::default(), &symbols), Some(HitKind::Fill));
assert_eq!(hit_any(&group, Point::new(50.0, 50.0), HitOptions::default(), &symbols), None);
}
/// The pointer moving in a blend's stroke band evaluates the blend once, and again after the blend
/// or a symbol of the document changes.
#[test]
fn clicks_in_a_blends_stroke_band_evaluate_it_once_until_it_changes() {
let mut d = Document::new(1000.0, 1000.0);
let l = d.layers[0].id;
let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0);
let circle = |id, x: f64| Arc::new(Node::path(id, shapes::ellipse(Rect::new(x, 295.0, x + 10.0, 305.0)), stroked.clone()));
let blend = d.alloc_id();
let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)];
let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() };
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap();
let evaluated = || LIVE_EVALUATED.with(std::cell::Cell::get);
let before = evaluated();
for x in [448.0, 450.0, 452.0] {
assert_eq!(hit_test(&d, Point::new(x, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend));
}
assert_eq!(evaluated(), before + 1);
// 100 steps 8.7 pt apart: a step still reaches (450, 291).
if let Some(NodeKind::Blend { spec, .. }) = d.node_mut(blend).map(|n| &mut n.kind) {
spec.spacing = crate::live::BlendSpacing::Steps(100);
}
assert_eq!(hit_test(&d, Point::new(450.0, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend));
assert_eq!(evaluated(), before + 2);
let art = circle(d.alloc_id(), 0.0);
d.symbols.push(crate::Symbol { dynamic: false, name: "dot".into(), art });
hit_test(&d, Point::new(450.0, 291.0), HitOptions::default());
assert_eq!(evaluated(), before + 3);
hit_test(&d, Point::new(450.0, 291.0), HitOptions::default());
assert_eq!(evaluated(), before + 3);
}
/// Evaluated art larger than [`LIVE_KEPT_SIZE`] is not kept: each click in the blend's stroke band
/// evaluates it again.
#[test]
fn evaluated_art_past_the_kept_size_is_not_kept() {
let mut d = Document::new(1000.0, 1000.0);
let l = d.layers[0].id;
let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0);
// A 10 pt circle drawn with 2,000 anchors: 202 objects of 2,000 anchors each with 200 steps.
let circle = |id, x: f64| {
let points: Vec<Point> = (0..2000)
.map(|i| {
let a = f64::from(i) * std::f64::consts::TAU / 2000.0;
Point::new(x + 5.0 + 5.0 * a.cos(), 300.0 + 5.0 * a.sin())
})
.collect();
let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&points, true));
Arc::new(Node::path(id, path, stroked.clone()))
};
let blend = d.alloc_id();
let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)];
let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() };
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap();
let evaluated = || LIVE_EVALUATED.with(std::cell::Cell::get);
let before = evaluated();
for x in [448.0, 450.0] {
assert_eq!(hit_test(&d, Point::new(x, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend));
}
assert_eq!(evaluated(), before + 2);
}
/// While the document's symbols hold a placed document, whose art changes when its file is read,
/// each click in a blend's stroke band evaluates the blend again.
#[test]
fn evaluated_art_is_not_kept_while_a_placed_document_may_show_in_it() {
let mut d = Document::new(1000.0, 1000.0);
let l = d.layers[0].id;
let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0);
let circle = |id, x: f64| Arc::new(Node::path(id, shapes::ellipse(Rect::new(x, 295.0, x + 10.0, 305.0)), stroked.clone()));
let blend = d.alloc_id();
let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)];
let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() };
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap();
let placed = crate::PlacedDocument {
link: crate::LinkInfo::new("/art/logo.vectorcraft"),
key: "logo".into(),
bounding: false,
page_box: crate::PageBox::Crop,
width: 80.0,
height: 40.0,
xf: vectorcraft_geom::Affine::IDENTITY,
placement: Default::default(),
};
let art = Arc::new(Node::new(d.alloc_id(), NodeKind::PlacedDocument(Box::new(placed))));
d.symbols.push(crate::Symbol { dynamic: false, name: "logo".into(), art });
let evaluated = || LIVE_EVALUATED.with(std::cell::Cell::get);
let before = evaluated();
for x in [448.0, 450.0] {
assert_eq!(hit_test(&d, Point::new(x, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend));
}
assert_eq!(evaluated(), before + 2);
}
/// A kept evaluation is the evaluation of the object only while the fonts and plug-ins
/// ([`LiveInputs`]) are as they were.
#[test]
fn a_kept_evaluation_is_stale_after_the_live_inputs_change() {
let d = Document::new(100.0, 100.0);
let n = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
let e = EvaluatedLive { node: n.clone(), symbols: vec![], patterns: vec![], inputs: 7, art: std::rc::Rc::new(vec![]), size: 0 };
assert!(e.is_of(&d, &n, 7));
assert!(!e.is_of(&d, &n, 8));
}
/// A rectangle (40, 40)–(140, 140) clipping a filled 200 × 200 square drawn over its edges, in a
/// clip group (or, `layer`, as the first layer's clipping mask): the group (or the layer), the
/// clipping path and the art.
+7 -1
View File
@@ -198,7 +198,8 @@ impl Node {
/// ([`Appearance::outset`]) instead of measuring along their shape. An image's box takes in
/// the shapes its own fills and strokes paint ([`crate::hit::set_image_paint`]). Groups, layers
/// without a clipping mask and Intertwine groups take in their members' boxes, grown by their
/// own strokes. Symbol instances are measured as in [`Node::visual_bounds`].
/// own strokes. Symbol instances are measured as in [`Node::visual_bounds`]. A blend's box
/// takes in where its spine can put its keys and steps.
pub fn reach_bounds(&self) -> Option<Rect> {
self.reach(None)
}
@@ -234,6 +235,11 @@ impl Node {
let o = if let crate::AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 };
vectorcraft_geom::union_opt(acc, (!bp.elements().is_empty()).then(|| bp.bounding_box().inflate(o, o)))
}),
// Keys and steps placed on a spine, turned to it with Align to Path, can reach past the
// keys and the spine.
NodeKind::Blend { children, spec } => {
vectorcraft_geom::union_opt(self.visual(symbols), crate::blend::rail_reach(children, spec, symbols))
}
_ => self.visual(symbols),
}
}
+42 -1
View File
@@ -1,6 +1,7 @@
//! Document Tools (`addon.document.*`), a built-in add-on: Save a Timestamped Copy writes a copy of
//! the document named with the date and time through File › Save a Copy's save; New Symbols from
//! Layers makes a symbol of each layer's art as New Symbol does. Its submenu (Object › Plug-ins ›
//! Layers makes a symbol of each layer's art as New Symbol does; Export All Open Documents writes a
//! copy of every open document to one folder as Save a Copy does (#4920). Its submenu (Object › Plug-ins ›
//! Document Tools) is the UI's.
#[cfg(test)]
@@ -30,6 +31,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_clock,
save_timestamped_copy
),
cmd!(
"addon.document.exportAllOpen",
"Export All Open Documents",
["Object", "Plug-ins", "Document Tools"],
None,
"{folder, format?: vectorcraft|pdf|svg|svgz|ai|eps (default: pdf)} write a copy of every open document, in tab order, to `folder` as File › Save a Copy writes it, each named after its document (<name>.<ext>; -2, -3… added when that name is taken, so no file is replaced): every document keeps its path, title and modified state, and no undo step is made. A document that can't be written is listed in `failed` and the others are still written; the active document stays active → {files: [{title, path, warnings}], failed: [{title, error}], format}",
has_doc,
export_all_open
),
cmd!(
"addon.document.symbolsFromLayers",
"New Symbols from Layers",
@@ -88,6 +98,37 @@ fn save_timestamped_copy(s: &mut Session, p: &Value) -> Result<Value> {
fileio::save_document(s, SaveMode::Copy, &json!({ "path": path, "format": format.id }))
}
fn export_all_open(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.document.exportAllOpen";
let format = fileio::save_format(Some(str_param(p, "format").unwrap_or("pdf")), None).map_err(|e| bad(C, e))?;
let folder = str_param(p, "folder").map(str::trim).filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "give `folder`"))?.to_string();
let ext = format.extensions.first().copied().ok_or_else(|| bad(C, "the format has no file extension"))?;
let (mut files, mut failed, mut written) = (vec![], vec![], Vec::<String>::new());
let active = s.active;
for i in 0..s.docs.len() {
// Each document in turn is the one Save a Copy writes; switching tabs this way leaves every
// document's tool and interaction alone, and the active one comes back below.
s.active = Some(i);
let Ok(st) = s.doc() else { continue };
let title = st.doc.title.clone();
let stem = fileio::file_stem(st.path.as_deref().unwrap_or(&title));
// Names the copies already took count as taken, as do files on disk.
let Some(path) = free_name(&folder, &stem, ext, |p| written.iter().any(|w| w == p) || std::path::Path::new(p).exists()) else {
failed.push(json!({ "title": title, "error": "every numbered name for the copy is taken" }));
continue;
};
match fileio::save_document(s, SaveMode::Copy, &json!({ "path": path, "format": format.id })) {
Ok(r) => {
written.push(path.clone());
files.push(json!({ "title": title, "path": r.get("path").cloned().unwrap_or(json!(path)), "warnings": r.get("warnings").cloned().unwrap_or(json!([])) }));
}
Err(e) => failed.push(json!({ "title": title, "error": e.to_string() })),
}
}
s.active = active;
Ok(json!({ "files": files, "failed": failed, "format": format.id }))
}
/// `n` with each layer in it, and itself, made a group of the same art and clipping (a symbol holds
/// no layers).
fn without_layers(n: &Node) -> Node {
@@ -194,3 +194,26 @@ fn the_layers_highlighted_in_the_layers_panel_are_the_default() {
let r = s.execute("addon.document.symbolsFromLayers", &json!({})).unwrap();
assert_eq!((r["symbols"].clone(), r["skipped"].as_array().unwrap().len()), (json!(["Icons"]), 1));
}
/// #4920: Export All Open Documents writes an SVG of each open document; a folder that can't be
/// written lists every document in `failed`, and either way the active document stays active.
#[test]
fn export_all_open_documents_writes_each_and_reports_failures() {
let dir = std::env::temp_dir().join(format!("vc-export-all-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100, "name": "one"})).unwrap();
s.execute("file.new", &json!({"width": 100, "height": 100, "name": "two"})).unwrap();
s.execute("document.activate", &json!({"index": 0})).unwrap();
let r = s.execute("addon.document.exportAllOpen", &json!({"folder": dir, "format": "svg"})).unwrap();
assert_eq!(r["files"].as_array().unwrap().len(), 2, "{r}");
assert!(dir.join("one.svg").exists() && dir.join("two.svg").exists());
assert_eq!(s.active_index(), Some(0));
assert!(s.documents().iter().all(|d| d.path.is_none()), "the documents stay unsaved");
let missing = dir.join("missing").join("deeper");
let r = s.execute("addon.document.exportAllOpen", &json!({"folder": missing, "format": "svg"})).unwrap();
assert_eq!((r["files"].as_array().unwrap().len(), r["failed"].as_array().unwrap().len()), (0, 2), "{r}");
assert_eq!(s.active_index(), Some(0));
let _ = std::fs::remove_dir_all(&dir);
}
+110 -3
View File
@@ -2,7 +2,8 @@
//! the Extend Path tool's extension, the Measure & Inspect tool's readouts, the Anchor Inspector's
//! anchor edits, Split / Add Points at Crossings, Slide Anchor Along Path, the Corner Tool's corners
//! ([`corners`]), the Parametric Shape Tool's shapes ([`shape`]), the Shared Edge Merge Tool's
//! merges with Keep Shared Points Together ([`shared_edge`]), and Divide into Dashes.
//! merges with Keep Shared Points Together ([`shared_edge`]), Divide into Dashes and Expand Dashes
//! into Paths.
//! Its tools, panels and submenu (Object › Plug-ins › Path Precision Tools) are the tools crate's
//! and the UI's.
@@ -17,7 +18,7 @@ mod tests;
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Value, json};
use vectorcraft_doc::{AnchorRef, Document, NodeId, NodeKind};
use vectorcraft_doc::{AnchorRef, Appearance, AppearanceItem, Document, LineCap, NodeId, NodeKind};
use vectorcraft_geom::{PathData, Point, SubPath};
use vectorcraft_pathops::addons::{self as po, pathtools as pt};
@@ -40,7 +41,7 @@ const MIN_RADIUS: f64 = 0.05;
const MAX_DIAMETER: f64 = 2500.0;
/// The farthest an end is extended at once (pt).
const MAX_EXTEND: f64 = 1e6;
/// Most pieces Divide into Dashes makes at once.
/// Most pieces Divide into Dashes and Expand Dashes into Paths make at once.
const MAX_PIECES: usize = 100_000;
/// Most anchors Add Points puts on each segment.
const MAX_ADD: u64 = pt::MAX_ADDED_PER_SEGMENT as u64;
@@ -164,6 +165,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
divide_dashes
),
cmd!(
"addon.pathtools.expandDashes",
"Expand Dashes into Paths",
["Object", "Plug-ins", "Path Precision Tools"],
None,
"{ids?: [id…] (default: the selection)} turn the dashed stroke of each path and compound path under the objects into separate open paths, one per dash, laid out as the stroke draws them (its dash pattern, offset and Align Dashes to Corners and Path Ends), each keeping the path's appearance with the dashes off, so the gaps are real gaps: a dash running all the way round a closed path stays closed, and with a round or projecting cap a dash of no length (a dot) becomes a path on its point. The top stroke's dashes are the ones expanded; its arrowheads go, and the path's fills stay on it, below its dashes. Paths without a dashed stroke stay as they are. One undo step; the dashes are selected; at most 100000 → {ids, dashes}",
has_selection,
expand_dashes
),
];
v.extend(shape::specs());
v.extend(corners::specs());
@@ -793,3 +803,100 @@ fn divide_dashes(s: &mut Session, p: &Value) -> Result<Value> {
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "dashes": dashes }))
}
// ---------- Expand Dashes into Paths ----------
/// The paths and compound paths under `roots` whose top stroke is dashed: a compound path's
/// members stand for it, which holds their paint. Clipping paths are left alone.
fn dashed_paths(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
let editable = d.editable();
let mut seen = BTreeSet::new();
let mut out = vec![];
for id in paths_under(d, roots) {
let compound = d.parent_of(id).and_then(|p| d.node(p)).filter(|p| matches!(p.kind, NodeKind::Compound { .. }));
let Some(n) = compound.or_else(|| d.node(id)) else { continue };
let dashed = n.appearance.stroke().and_then(|st| st.dash.as_ref()).is_some_and(|dash| dash.is_dashed());
if dashed && !matches!(n.kind, NodeKind::Path { clipping: true, .. }) && editable(n.id) && seen.insert(n.id) {
out.push(n.id);
}
}
out
}
fn expand_dashes(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.expandDashes";
let too_many = || bad(C, "that makes too many pieces (at most 100000): make the dashes longer");
let roots = checked_ids_or_selection(s, p, C)?;
let d = &s.doc()?.doc;
// Each dashed path and its dashes, as the canvas draws them.
let mut changed: Vec<(NodeId, Vec<SubPath>)> = vec![];
let mut count = 0;
for id in dashed_paths(d, &roots) {
let Some(n) = d.node(id) else { continue };
let (Some(st), Some(bp)) = (n.appearance.stroke(), n.stroke_path()) else { continue };
let Some(dash) = &st.dash else { continue };
// The line the stroke draws: the path, its ends trimmed under any arrowheads.
let line = vectorcraft_render::effects::stroke::stroke_pieces(&bp, st).line;
// A pattern too fine to draw is drawn solid: it has too many dashes to expand.
let dashed = vectorcraft_render::effects::stroke::dash(&line, dash).ok_or_else(too_many)?;
let mut pieces = PathData::from_bezpath(&dashed.path).subpaths;
// A dot paints its cap; a path that stays on its point is drawn so too. Under a butt cap it
// paints nothing.
if st.cap != LineCap::Butt {
pieces.extend(dashed.dots.iter().map(|dot| SubPath::polyline(&[dot.at, dot.at], false)));
}
count += pieces.len();
if count > MAX_PIECES {
return Err(too_many());
}
if !pieces.is_empty() {
changed.push((id, pieces));
}
}
if changed.is_empty() {
return Err(bad(C, "select paths with dashed strokes to expand"));
}
let ids = s.edit("Expand Dashes into Paths", |d, sel| {
let mut all = vec![];
let mut beside = Beside::replacing(d, &changed.iter().map(|(id, _)| *id).collect::<Vec<_>>());
for (id, pieces) in changed {
let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?;
let (NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. }) = &n.kind else { continue };
let rule = *rule;
let filled = |i: &AppearanceItem| matches!(i, AppearanceItem::Fill(f) if f.visible && !f.paint.is_none());
// The dashes: open paths with the strokes (the top one's dashes and arrowheads off).
let mut look = Appearance::clone(&n.appearance);
look.items.retain(|i| !matches!(i, AppearanceItem::Fill(_)));
if let Some(st) = look.stroke_mut() {
(st.dash, st.start_arrow, st.end_arrow) = (None, None, None);
}
let mut dash = n.clone();
dash.kind = NodeKind::Path { path: PathData::default(), rule, live: None, clipping: false, guide: false };
dash.appearance = look.into();
// The fills stay on the path, below its dashes; without fills it goes.
let mut parts = vec![];
if n.appearance.items.iter().any(filled) {
let mut fill = n.clone();
let mut look = Appearance::clone(&n.appearance);
look.items.retain(filled);
fill.appearance = look.into();
parts.push(fill);
}
for sp in pieces {
let mut piece = d.reid(&dash);
if let NodeKind::Path { path, .. } = &mut piece.kind {
*path = PathData::single(sp);
}
all.push(piece.id);
parts.push(piece);
}
beside.put(d, id, parts)?;
}
beside.finish(d)?;
// The dashes join the objects selected (whole, in one go: there may be many).
let before = std::mem::take(&mut sel.objects);
sel.set(before.into_iter().filter(|id| d.node(*id).is_some()).chain(all.iter().copied()));
Ok(all)
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "dashes": ids.len() }))
}
@@ -748,3 +748,126 @@ fn divide_into_dashes_refuses_bad_params_and_too_many_pieces() {
s.execute("select.none", &json!({})).unwrap();
refuses(&mut s, "addon.pathtools.divideDashes", &[json!({})]);
}
/// The dashes made of `r`'s ids: (first x, last x) of each, by its anchors.
fn dash_spans(s: &Session, r: &Value) -> Vec<(f64, f64)> {
ids(r).iter().map(|id| path(s, *id).bounds().map(|b| (b.x0, b.x1)).unwrap()).collect()
}
fn near(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6)
}
#[test]
fn expand_dashes_makes_an_open_path_of_each_dash_keeping_the_stroke() {
let mut s = session();
let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0)], false);
select(&mut s, &[l]);
s.execute("paint.setFill", &json!({"none": true})).unwrap();
s.execute("stroke.set", &json!({"weight": 3, "cap": "round", "dash": [30, 10]})).unwrap();
let n = steps(&s);
let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap();
assert_eq!(steps(&s), n + 1, "one undo step");
assert!(near(&dash_spans(&s, &r), &[(0.0, 30.0), (40.0, 70.0), (80.0, 100.0)]), "{:?}", dash_spans(&s, &r));
assert_eq!(r["dashes"], json!(3));
let st = s.doc().unwrap();
for id in ids(&r) {
let node = st.doc.node(id).unwrap();
let stroke = node.appearance.stroke().unwrap();
assert_eq!((stroke.width, stroke.cap, stroke.dash.clone()), (3.0, vectorcraft_doc::LineCap::Round, None), "the stroke stays, dashes off");
assert!(!path(&s, id).subpaths[0].closed);
}
assert_eq!(st.selection.objects, ids(&r));
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(l).unwrap().appearance.stroke().unwrap().dash.is_some());
// The offset shifts the pattern as the stroke draws it: 20 into the first dash.
s.execute("stroke.set", &json!({"dashOffset": 20})).unwrap();
let r = s.execute("addon.pathtools.expandDashes", &json!({"ids": [l.0]})).unwrap();
assert!(near(&dash_spans(&s, &r), &[(0.0, 10.0), (20.0, 50.0), (60.0, 90.0)]), "{:?}", dash_spans(&s, &r));
}
#[test]
fn expand_dashes_keeps_the_fill_below_and_follows_aligned_corners() {
let mut s = session();
let rect = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 60})).unwrap());
select(&mut s, &[rect]);
s.execute("stroke.set", &json!({"dash": [20, 20]})).unwrap();
let had_fill = !s.doc().unwrap().doc.node(rect).unwrap().appearance.fill_paint().is_none();
let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap();
// 320 round: eight dashes of 20, a dash bending round each corner it meets.
let pieces = ids(&r);
assert_eq!(pieces.len(), 8);
let st = s.doc().unwrap();
let layer: Vec<NodeId> = st.doc.layers[0].children().unwrap().iter().map(|n| n.id).collect();
if had_fill {
let kept = st.doc.node(rect).unwrap();
assert!(kept.appearance.stroke().is_none() && kept.appearance.fill().is_some(), "the fill stays on the rectangle");
assert_eq!(layer, [vec![rect], pieces.clone()].concat(), "the fill below its dashes");
} else {
assert_eq!(layer, pieces);
}
assert!(pieces.iter().all(|id| st.doc.node(*id).unwrap().appearance.fill().is_none()), "an open dash isn't filled");
s.execute("edit.undo", &json!({})).unwrap();
// Aligned to corners: a dash centred on each corner, the four sides each starting and ending
// on half a dash; 12 dashes along the sides.
s.execute("stroke.set", &json!({"dash": [10, 10], "alignDashes": true})).unwrap();
let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap();
let corners = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 60.0), Point::new(0.0, 60.0)];
let mut centred = 0;
for id in ids(&r) {
let sp = path(&s, id).subpaths[0].clone();
if let [a, b, c] = sp.anchors.as_slice()
&& corners.iter().any(|k| b.p.distance(*k) < 1e-6)
{
assert!((a.p.distance(b.p) - 5.0).abs() < 1e-6 && (c.p.distance(b.p) - 5.0).abs() < 1e-6, "{sp:?}");
centred += 1;
}
}
assert_eq!((ids(&r).len(), centred), (16, 4));
}
#[test]
fn expand_dashes_turns_dots_into_paths_on_their_point_and_releases_compound_paths() {
let mut s = session();
let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0)], false);
select(&mut s, &[l]);
s.execute("stroke.set", &json!({"dash": [0, 20], "cap": "round"})).unwrap();
let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap();
assert_eq!(ids(&r).len(), 6, "a dot every 20 from 0 to 100");
assert!(ids(&r).iter().all(|id| path(&s, *id).bounds().unwrap().width() == 0.0));
s.execute("edit.undo", &json!({})).unwrap();
// Under a butt cap a dot paints nothing, so there is nothing to expand.
s.execute("stroke.set", &json!({"cap": "butt"})).unwrap();
refuses(&mut s, "addon.pathtools.expandDashes", &[json!({})]);
// A compound path's dashes are paths of their own with its stroke.
let a = line(&mut s, &[(0.0, 100.0), (50.0, 100.0)], false);
let b = line(&mut s, &[(0.0, 120.0), (50.0, 120.0)], false);
select(&mut s, &[a, b]);
let c = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap());
s.execute("stroke.set", &json!({"weight": 2, "dash": [20, 10], "cap": "butt"})).unwrap();
let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap();
// Each 50 line: 20, gap, 20.
let st = s.doc().unwrap();
assert_eq!(ids(&r).len(), 4);
for id in ids(&r) {
let n = st.doc.node(id).unwrap();
assert!(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. }), "a plain path");
assert_eq!(n.appearance.stroke_width(), 2.0);
assert!(n.appearance.stroke().unwrap().dash.is_none());
}
assert!(st.doc.node(c).is_none_or(|n| n.appearance.stroke().is_none()), "the compound path's dashes are gone from it");
}
#[test]
fn expand_dashes_refuses_without_dashes_and_past_the_limit() {
let mut s = session();
let l = line(&mut s, &[(0.0, 0.0), (1e5, 0.0)], false);
refuses(&mut s, "addon.pathtools.expandDashes", &[json!({})]);
select(&mut s, &[l]);
refuses(&mut s, "addon.pathtools.expandDashes", &[json!({}), json!({"ids": "x"}), json!({"zzz": 1})]);
// 125000 dashes refused; 1000 are fine.
s.execute("stroke.set", &json!({"dash": [0.4, 0.4]})).unwrap();
refuses(&mut s, "addon.pathtools.expandDashes", &[json!({})]);
s.execute("stroke.set", &json!({"dash": [50, 50]})).unwrap();
assert_eq!(s.execute("addon.pathtools.expandDashes", &json!({})).unwrap()["dashes"], json!(1000));
}
@@ -82,6 +82,28 @@ fn an_illustrator_ai_file_opens_from_its_editing_data() {
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden"]);
}
#[test]
fn an_ai_unread_group_keeps_its_layers_and_live_neighbor() {
let live = format!("0 0 1 0 k\n{}f", ai::rect(10.0, 10.0, 14.0, 14.0));
let unread = format!("u\n0 0 1 0 k\n{}f\nZZ\nU\n{}", ai::rect(50.0, 10.0, 54.0, 14.0), ai::named("Unread"));
let data = ai::editing_data(200.0, 100.0, &(ai::layer("Live", &live) + &ai::layer("Mixed", &unread)));
let page = "0 0 1 0 k 10 10 4 4 re f 1 0 0 0 k 50 10 4 4 re f";
let mut session = Session::new();
let result = open(&mut session, "unread.ai", &ai::ai(&compressed(data.as_bytes()), page), json!({})).unwrap();
let doc = &session.doc().unwrap().doc;
assert_eq!(names(&doc.layers), ["Live", "Mixed"], "{result}");
assert!(matches!(doc.layers[0].children().unwrap()[0].kind, NodeKind::Path { .. }), "{result}");
assert!(matches!(doc.layers[1].children().unwrap()[0].kind, NodeKind::Group { .. }), "{result}");
assert_eq!(doc.layers[1].children().unwrap()[0].name.as_deref(), Some("Unread"), "{result}");
let replacement = &doc.layers[1].children().unwrap()[0].children().unwrap()[0];
assert!(
matches!(replacement.appearance.items.first(), Some(vectorcraft_doc::AppearanceItem::Fill(fill))
if fill.paint == vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(1.0, 0.0, 0.0, 0.0))),
"{result}"
);
assert!(result["warnings"].to_string().contains("`ZZ` (1)"), "{result}");
}
/// A palette of three swatches and a color group `Inks` holding a fourth.
const INKS_PALETTE: &str = "%AI5_BeginPalette\n0 0 Pb\n0 0 0 0 1 1 1 Xa\n(White)\nPc\n0.04 0.3 0 0 81.96 21 -8 (Pink ink) 0 2 Xx\n(Pink ink)\nPc\n\
0.06 0.98 1 0 (Red ink) 0 x\n(Red ink)\nPc\n1 (Inks) 1 Pg\n0.02 0.1 1 0 (Yellow ink) 0 x\n(Yellow ink)\nPc\nPB\n%AI5_EndPalette\n";
+5
View File
@@ -20,6 +20,11 @@ impl Session {
});
// Clicks find an image's own fills and strokes where their effects draw them.
vectorcraft_doc::hit::set_image_paint(vectorcraft_render::effects::image_paint);
// Clicks find a blend's steps and an envelope's distorted art where the canvas draws them,
// evaluated again when the fonts or the plug-ins change.
vectorcraft_doc::hit::set_live_art(vectorcraft_render::effects::expand_live, || {
vectorcraft_text::FontDb::global().generation().rotate_left(32) ^ vectorcraft_render::effects::plugin_revision()
});
// Exports bake Vector Halftone into the dots the renderer samples.
vectorcraft_render::effects::addons::halftone::set_art(vectorcraft_render::addons::halftone::art);
// OpenType SVG glyphs are drawn as the SVG importer reads their documents.
+3
View File
@@ -600,6 +600,9 @@ fn structured_junk() {
("addon.pathtools.divideDashes", json!({"dash": 1e-308, "gap": -1e308, "fit": "x"})),
("addon.pathtools.divideDashes", json!({"dash": 0.01, "gap": 1e6, "fit": true, "removeGaps": false})),
("addon.pathtools.divideDashes", json!({"dash": f64::MAX, "gap": [0], "removeGaps": null})),
("addon.pathtools.expandDashes", json!({})),
("addon.pathtools.expandDashes", json!({"ids": [0, 1, 2, 3, u64::MAX]})),
("addon.pathtools.expandDashes", json!({"ids": "x", "junk": f64::MAX})),
// Refused before anything is written: no format of that name, a time that is no time.
("addon.document.saveTimestampedCopy", json!({"format": "png", "folder": 7})),
("addon.document.saveTimestampedCopy", json!({"format": "svg", "time": [1e308], "folder": null})),
+217 -14
View File
@@ -1,6 +1,6 @@
//! Untrusted text files placed with File → Place never crash: any bytes, with any Text Import
//! Options, place as area type or fail with an error, and so do hostile RTF files, Word documents
//! and Word 97-2003 documents.
//! and Word 97-2003 documents, including ones whose compound file container is damaged.
//!
//! `PROPTEST_CASES=20000 cargo test -p vectorcraft-engine --test text_import_fuzz` runs a deeper search.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
@@ -38,8 +38,8 @@ proptest! {
"replaceSpaces": spaces,
});
let p = json!({"name": "fuzz.txt", "dataBase64": vectorcraft_format::base64_encode(&data), "text": text, "thumbnail": 8});
let _ = s.execute("file.place.info", &p);
let _ = s.execute("file.place", &p);
let _ = no_panic(s.execute("file.place.info", &p));
let _ = no_panic(s.execute("file.place", &p));
});
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
}
@@ -55,8 +55,8 @@ proptest! {
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&data), "thumbnail": 8});
let _ = s.execute("file.place.info", &p);
let _ = s.execute("file.place", &p);
let _ = no_panic(s.execute("file.place.info", &p));
let _ = no_panic(s.execute("file.place", &p));
});
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
}
@@ -86,8 +86,8 @@ proptest! {
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&data), "thumbnail": 8});
let _ = s.execute("file.place.info", &p);
let _ = s.execute("file.place", &p);
let _ = no_panic(s.execute("file.place.info", &p));
let _ = no_panic(s.execute("file.place", &p));
});
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
}
@@ -117,20 +117,223 @@ proptest! {
let i = at.index(stream.len());
stream[i] = byte;
}
let mut cf = cfb::CompoundFile::create(std::io::Cursor::new(Vec::new())).unwrap();
std::io::Write::write_all(&mut cf.create_stream("WordDocument").unwrap(), &wd).unwrap();
std::io::Write::write_all(&mut cf.create_stream("1Table").unwrap(), &table).unwrap();
cf.flush().unwrap();
let data = cf.into_inner().into_inner();
let data = compound_file(&wd, &table, cfb::Version::V4);
let r = catch_quiet(|| {
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&data), "thumbnail": 8});
let _ = s.execute("file.place.info", &p);
let _ = s.execute("file.place", &p);
let _ = no_panic(s.execute("file.place.info", &p));
let _ = no_panic(s.execute("file.place", &p));
});
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
}
#[test]
fn any_damaged_doc_container_places_without_panics(
text in prop::collection::vec(prop::sample::select(DOC_CHARS.chars().collect::<Vec<_>>()), 1..60),
compressed in any::<bool>(),
v3 in any::<bool>(),
large in any::<bool>(),
damage in prop::collection::vec(arb_cfb_damage(), 1..4),
name in prop::sample::select(vec!["fuzz.doc", "fuzz.bin"]),
) {
vectorcraft_text::FontDb::global().set_system_fallback(false);
// A letter first, so that the undamaged document has text to place.
let text: String = std::iter::once('a').chain(text).collect();
let (mut wd, table) = word97(&text, compressed, &[], 0, &[], &[], &[]);
// Past the mini stream cutoff (4,096 bytes) the WordDocument stream is kept in sectors the
// FAT chains; below it, both streams are kept in the mini stream the mini FAT chains.
if large {
wd.resize(9000, 0);
}
let mut data = compound_file(&wd, &table, if v3 { cfb::Version::V3 } else { cfb::Version::V4 });
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let p = json!({"name": "fuzz.doc", "dataBase64": vectorcraft_format::base64_encode(&data)});
let placed = s.execute("file.place", &p);
prop_assert!(placed.is_ok(), "the undamaged document doesn't place: {:?}", placed.err());
let layout = CfbLayout::of(&data);
for d in &damage {
d.apply(&mut data, &layout);
}
let r = catch_quiet(|| {
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&data), "thumbnail": 8});
let _ = no_panic(s.execute("file.place.info", &p));
let _ = no_panic(s.execute("file.place", &p));
});
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
}
}
/// `r`, after failing the case if the command panicked. The engine's guard reports a panic as
/// `EngineError::Internal`.
fn no_panic(r: Result<serde_json::Value, vectorcraft_engine::EngineError>) -> Result<serde_json::Value, vectorcraft_engine::EngineError> {
if let Err(vectorcraft_engine::EngineError::Internal { cmd, msg }) = &r {
panic!("{cmd} panicked: {msg}");
}
r
}
/// A compound file of `version` holding the WordDocument stream `wd` and the table stream `table`.
fn compound_file(wd: &[u8], table: &[u8], version: cfb::Version) -> Vec<u8> {
let mut cf = cfb::CompoundFile::create_with_version(version, std::io::Cursor::new(Vec::new())).unwrap();
std::io::Write::write_all(&mut cf.create_stream("WordDocument").unwrap(), wd).unwrap();
std::io::Write::write_all(&mut cf.create_stream("1Table").unwrap(), table).unwrap();
cf.flush().unwrap();
cf.into_inner().into_inner()
}
/// The compound file header's fields (\[MS-CFB\] §2.2) a damage changes: their offset and width.
const CFB_HEADER: [(usize, usize); 17] = [
(0, 4), // signature
(24, 2), // minor version
(26, 2), // major version
(28, 2), // byte order
(30, 2), // sector shift
(32, 2), // mini sector shift
(40, 4), // directory sectors
(44, 4), // FAT sectors
(48, 4), // first directory sector
(52, 4), // transaction signature
(56, 4), // mini stream cutoff
(60, 4), // first mini FAT sector
(64, 4), // mini FAT sectors
(68, 4), // first DIFAT sector
(72, 4), // DIFAT sectors
(76, 4), // DIFAT[0]
(80, 4), // DIFAT[1]
];
/// A directory entry's fields (\[MS-CFB\] §2.6.1) a damage changes: their offset and width.
const CFB_ENTRY: [(usize, usize); 10] = [
(0, 2), // first character of the name
(64, 2), // name length
(66, 1), // object type
(67, 1), // color
(68, 4), // left sibling
(72, 4), // right sibling
(76, 4), // child
(116, 4), // starting sector
(120, 4), // stream size, low
(124, 4), // stream size, high
];
/// Where an undamaged compound file keeps its directory, FAT and mini FAT.
struct CfbLayout {
/// The bytes of one sector.
sector: usize,
/// The offsets of the first directory sector, the first FAT sector and the first mini FAT
/// sector.
directory: usize,
fat: usize,
mini_fat: usize,
}
impl CfbLayout {
fn of(data: &[u8]) -> Self {
let u32_at = |at: usize| u32::from_le_bytes(data[at..at + 4].try_into().unwrap()) as usize;
let sector = 1 << u16::from_le_bytes(data[30..32].try_into().unwrap());
let at = |n: usize| (n + 1) * sector;
let layout = Self { sector, directory: at(u32_at(48)), fat: at(u32_at(76)), mini_fat: at(u32_at(60)) };
// The root entry's name opens the directory, the FAT marks its own sector, and the mini FAT
// has a sector.
assert_eq!(&data[layout.directory..layout.directory + 4], b"R\0o\0");
assert_eq!(u32_at(layout.fat + 4 * u32_at(76)), 0xFFFF_FFFD);
assert!(u32_at(64) >= 1);
layout
}
}
/// A change to the container of a compound file rather than to the bytes of its streams.
#[derive(Clone, Debug)]
enum CfbDamage {
/// A header field set to a value.
Header(prop::sample::Index, u32),
/// A field of one of the first directory sector's entries set to a value.
Entry(prop::sample::Index, prop::sample::Index, u32),
/// An entry of the first FAT sector (the next sector of a chain) set to a value.
Fat(prop::sample::Index, u32),
/// An entry of the first mini FAT sector set to a value.
MiniFat(prop::sample::Index, u32),
/// A byte anywhere set to a value.
Byte(prop::sample::Index, u8),
/// The file cut short.
Truncate(prop::sample::Index),
}
impl CfbDamage {
fn apply(&self, data: &mut Vec<u8>, layout: &CfbLayout) {
let mut put = |at: usize, width: usize, value: u32| {
if let Some(b) = data.get_mut(at..at + width) {
b.copy_from_slice(&value.to_le_bytes()[..width]);
}
};
match self {
Self::Header(field, value) => {
let (at, width) = CFB_HEADER[field.index(CFB_HEADER.len())];
put(at, width, *value);
}
Self::Entry(entry, field, value) => {
let (at, width) = CFB_ENTRY[field.index(CFB_ENTRY.len())];
put(layout.directory + 128 * entry.index(layout.sector / 128) + at, width, *value);
}
Self::Fat(entry, value) => put(layout.fat + 4 * entry.index(layout.sector / 4), 4, *value),
Self::MiniFat(entry, value) => put(layout.mini_fat + 4 * entry.index(layout.sector / 4), 4, *value),
// A file cut to nothing has no byte to change and nothing to cut.
Self::Byte(..) | Self::Truncate(_) if data.is_empty() => {}
Self::Byte(at, value) => {
let i = at.index(data.len());
data[i] = *value;
}
Self::Truncate(at) => data.truncate(at.index(data.len())),
}
}
}
/// A sector number, size, count or shift: the special sector numbers (\[MS-CFB\] §2.1), small and
/// huge values, or any.
fn arb_cfb_value() -> impl Strategy<Value = u32> {
prop_oneof![
3 => prop::sample::select(vec![
0,
1,
2,
3,
6,
7,
9,
12,
13,
16,
31,
64,
0x1000,
0x1001,
0x7FFF_FFFF,
0xFFFF_FFFA,
0xFFFF_FFFB,
0xFFFF_FFFC,
0xFFFF_FFFD,
0xFFFF_FFFE,
0xFFFF_FFFF,
]),
1 => any::<u32>(),
]
}
/// A [`CfbDamage`].
fn arb_cfb_damage() -> impl Strategy<Value = CfbDamage> {
let index = any::<prop::sample::Index>;
prop_oneof![
3 => (index(), arb_cfb_value()).prop_map(|(f, v)| CfbDamage::Header(f, v)),
3 => (index(), index(), arb_cfb_value()).prop_map(|(e, f, v)| CfbDamage::Entry(e, f, v)),
2 => (index(), arb_cfb_value()).prop_map(|(e, v)| CfbDamage::Fat(e, v)),
2 => (index(), arb_cfb_value()).prop_map(|(e, v)| CfbDamage::MiniFat(e, v)),
1 => (index(), any::<u8>()).prop_map(|(at, v)| CfbDamage::Byte(at, v)),
1 => index().prop_map(CfbDamage::Truncate),
]
}
/// Characters of a Word document's text: letters, marks of paragraphs, cells and breaks, fields,
+1 -1
View File
@@ -34,7 +34,7 @@ use crate::ps;
pub(crate) use frames::text_frames;
pub use read::{IMAGE_TOO_LARGE, PaletteSwatches, Structure, artboards, palette, read, variables};
pub(crate) use read::{slot_frame, slot_of};
pub(crate) use read::{join_and, slot_frame, slot_of};
#[cfg(test)]
mod tests;
+2
View File
@@ -68,6 +68,8 @@ pub(super) struct Obj {
pub content: Vec<V>,
/// Vector art enclosed by `X=` … `X+` in an opacity-mask dictionary.
pub art: Option<std::sync::Arc<vectorcraft_doc::Node>>,
/// What that art has that isn't read, reported once the mask is known to be enabled.
pub unread: Vec<String>,
}
impl Obj {
+19 -1
View File
@@ -73,6 +73,7 @@ mod document;
mod ops;
mod types;
pub(crate) use art::join_and;
use art::preview_png;
pub use document::{PaletteSwatches, VariableLibrary, artboards, palette, variables};
pub(crate) use document::{slot_frame, slot_of};
@@ -101,6 +102,10 @@ pub struct Structure {
pub template: Option<[f64; 4]>,
/// What layers that don't show have that isn't read (operators, objects): left out of them.
pub hidden_unread: BTreeSet<String>,
/// Top-level object ids and the unread kinds within them.
pub unread: Vec<(NodeId, Vec<String>)>,
/// Other bounds for previewless placed files whose page art uses the file box's corner.
pub unread_elsewhere: Vec<(NodeId, Rect)>,
/// The names of text objects, by their slot's id.
pub slot_names: HashMap<NodeId, String>,
/// Where the drawn looks lay that objects read with their art style took the place of (the
@@ -162,6 +167,12 @@ struct Reader<'a> {
unknown: BTreeSet<String>,
/// What layers that don't show have that this reader doesn't read.
hidden_unread: BTreeSet<String>,
unread: Vec<(NodeId, Vec<String>)>,
symbol_bounds: HashMap<String, Rect>,
/// Depth shared by pattern and symbol readers.
subreader_depth: usize,
pending_unread_fill: bool,
pending_unread_stroke: bool,
/// The header's bounding boxes and template box.
bbox: Option<[f64; 4]>,
hires: Option<[f64; 4]>,
@@ -209,6 +220,7 @@ struct Reader<'a> {
restyled: Vec<Rect>,
/// See [`Structure::placed_elsewhere`].
placed_elsewhere: Vec<(NodeId, Affine)>,
unread_elsewhere: Vec<(NodeId, Rect)>,
/// CMYK samples converted, by value.
cmyk_cache: HashMap<[u8; 4], [u8; 3]>,
/// A `/ForeignObject`'s `/Data`: its PDF as ASCII85 in comment lines, until the object closes.
@@ -277,6 +289,11 @@ impl<'a> Reader<'a> {
unsupported: BTreeSet::new(),
unknown: BTreeSet::new(),
hidden_unread: BTreeSet::new(),
unread: Vec::new(),
symbol_bounds: HashMap::new(),
subreader_depth: 0,
pending_unread_fill: false,
pending_unread_stroke: false,
bbox: None,
hires: None,
template: None,
@@ -301,6 +318,7 @@ impl<'a> Reader<'a> {
palette: super::palette::Palette::default(),
restyled: vec![],
placed_elsewhere: vec![],
unread_elsewhere: vec![],
mesh: None,
cmyk_cache: HashMap::new(),
done: false,
@@ -332,7 +350,7 @@ impl<'a> Reader<'a> {
let w = std::str::from_utf8(w).unwrap_or("?");
self.op(w, hidden)?;
}
Tok::Comment(c) => self.comment(c)?,
Tok::Comment(c) => self.comment(c, hidden)?,
Tok::Data(d) => self.image(d)?,
Tok::Preview(d) => {
if let Some(p) = self.place.as_mut() {
+60 -8
View File
@@ -12,7 +12,7 @@ impl<'a> Reader<'a> {
/// A text object (`/AI11Text`): an empty group standing for it, named after its story.
pub(super) fn text_slot(&mut self, o: &Obj) -> Result<(), String> {
if self.pattern.is_some() {
self.unreadable("type in a pattern");
self.unreadable("type in a pattern", false);
}
// `1 /FreeUndo`: the type in the drawn look of a text object that has an appearance. Its
// story is one of another text document (`/AI11UndoFreeTextDocument`), which counts its
@@ -171,6 +171,8 @@ impl<'a> Reader<'a> {
"b" => (true, true),
_ => (false, false),
};
// A gradient instance's paint replaces the missing pattern.
let unread = (fill && self.pending_unread_fill && fill_paint.is_none()) || (stroke && self.pending_unread_stroke && stroke_paint.is_none());
let mut items = Vec::new();
if fill && !clip {
items.push(AppearanceItem::Fill(FillLayer {
@@ -212,7 +214,28 @@ impl<'a> Reader<'a> {
.into();
copy
});
let unread_bounds = unread.then(|| node.visual_bounds()).flatten();
let unread_id = node.id;
self.add(node, hidden)?;
if unread && self.pattern.is_some() {
// Art in a pattern's definition painted with a pattern it doesn't have: the tile can't be read.
self.unsupported.insert("pattern fills".into());
} else if unread && ((hidden && !self.in_mask()) || gs.hidden || !self.shown()) {
self.hidden_unread.insert("pattern fills".into());
} else if unread && self.in_mask() {
self.unreadable("pattern fills", false);
} else if unread {
match unread_bounds {
Some(b) if sane(b) && b.area() > 0.0 => {
if !self.mark_unread("pattern fills".into()) {
self.unread.push((unread_id, vec!["pattern fills".into()]));
}
}
_ => {
self.unsupported.insert("pattern fills".into());
}
}
}
if let Some(copy) = copy {
self.clip_fills.insert(copy.id);
self.add(copy, hidden)?;
@@ -224,7 +247,7 @@ impl<'a> Reader<'a> {
pub(super) fn end_mesh(&mut self) -> Result<(), String> {
let Some((m, hidden)) = self.mesh.take() else { return Ok(()) };
if m.unread || m.patches.is_empty() {
self.unreadable("gradient meshes it can't read");
self.unreadable("gradient meshes it can't read", hidden && self.pattern.is_none());
return Ok(());
}
let gs = self.gs.clone();
@@ -461,10 +484,39 @@ impl<'a> Reader<'a> {
/// shows the preview the data keeps of it, as a linked image whose file is missing does. One
/// without a preview, a path or a matrix isn't read.
pub(super) fn end_place(&mut self, hidden: bool) -> Result<(), String> {
let Some(Place { matrix: Some(m), bounds: Some(b), path, preview: Some((png, w, h)) }) = self.place.take().filter(|p| !p.path.is_empty())
else {
self.unreadable("placed files");
return Ok(());
let (m, b, path, png, w, h) = match self.place.take() {
Some(Place { matrix: Some(m), bounds: Some(b), path, preview: Some((png, w, h)) }) if !path.is_empty() => (m, b, path, png, w, h),
other => {
let boxed = other.as_ref().and_then(|p| Some((p.matrix?, p.bounds?))).filter(|(_, b)| sane(*b) && b.area() > 0.0);
let mut have_bounds = false;
if self.shown()
&& !self.gs.hidden
&& !hidden
&& let Some((m, b)) = boxed
{
let centre = self.template.map(|t| ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0));
let origin = centre.map_or(b.origin(), |(x, y)| Point::new(CANVAS_CENTRE - x, CANVAS_CENTRE + y));
let box_at_canvas = Rect::new(origin.x, origin.y, origin.x + b.width(), origin.y + b.height());
let bounds = (self.space() * m).transform_rect_bbox(box_at_canvas);
have_bounds = sane(bounds) && bounds.area() > 0.0;
if have_bounds {
let placeholder_id = self.unread_box("placed files", bounds, hidden)?;
if let Some(placeholder_id) = placeholder_id.filter(|_| self.unread_elsewhere.len() < MAX_PLACED_ELSEWHERE) {
let corner = (self.space() * m).transform_rect_bbox(b);
if sane(corner) && corner.area() > 0.0 && corner != bounds {
self.unread_elsewhere.push((placeholder_id, corner));
}
}
return Ok(());
}
}
if self.shown() && !self.gs.hidden && !hidden && !have_bounds {
self.unsupported.insert("placed files".into());
} else {
self.unreadable("placed files", hidden);
}
return Ok(());
}
};
// The file's pixels (y down) start at the canvas point of art space's origin (the box's own
// corner when the file doesn't say where the canvas is); the preview's are spread over them.
@@ -481,7 +533,7 @@ impl<'a> Reader<'a> {
.filter(|a| a.as_coeffs().iter().all(|v| v.is_finite()));
let xf = self.space() * m * over_file;
if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 {
self.unreadable("placed files");
self.unreadable("placed files", hidden);
return Ok(());
}
// The preview is all the document has of the file: what a missing link shows.
@@ -581,7 +633,7 @@ fn cmyk_rgb([c, m, y, k]: [u8; 4]) -> [u8; 3] {
}
/// `a`, `a and b`, `a, b and c`.
pub(super) fn join_and(items: &[&str]) -> String {
pub(crate) fn join_and(items: &[&str]) -> String {
match items {
[] => String::new(),
[one] => (*one).to_string(),
+143 -13
View File
@@ -21,9 +21,12 @@ impl<'a> Reader<'a> {
after: self.after_object,
mesh_ended: self.mesh_ended,
gs: self.gs.clone(),
pending_unread_fill: self.pending_unread_fill,
pending_unread_stroke: self.pending_unread_stroke,
base,
clips: Vec::new(),
compound_members: false,
unread: Vec::new(),
state: None,
});
self.after_object = false;
@@ -76,10 +79,27 @@ impl<'a> Reader<'a> {
"*U" => self.close(what, |k| matches!(k, Kind::Compound))?,
_ => self.close(what, |k| matches!(k, Kind::Clip))?,
};
let Frame { kind, children: kids, gs, clips, hidden: opened_hidden, compound_members, .. } = f;
let Frame {
kind,
children: kids,
gs,
clips,
hidden: opened_hidden,
compound_members,
unread,
pending_unread_fill,
pending_unread_stroke,
..
} = f;
if matches!(kind, Kind::Clip) {
self.gs = gs.clone();
self.pending_unread_fill = pending_unread_fill;
self.pending_unread_stroke = pending_unread_stroke;
}
// A group holding only clipping paths (`q … u <path> W n U … Q`) doesn't clip on its own: `u`/`U` don't save
// the graphics state as `q`/`Q` do, so its paths clip the clipping group around it, as the file's PDF part draws it.
if matches!(kind, Kind::Group)
&& unread.is_empty()
&& !clips.is_empty()
&& clips.len() == kids.len()
&& matches!(self.frames.last(), Some(Frame { kind: Kind::Clip, .. }))
@@ -109,7 +129,14 @@ impl<'a> Reader<'a> {
// painted copies make a compound of their own, under what it clips.
let (fills, kids): (Vec<Node>, Vec<Node>) = ungrouped(kids).into_iter().partition(|n| self.clip_fills.contains(&n.id));
painted = self.compound(fills, &gs);
let Some(n) = self.compound(kids, &gs) else { return Ok(()) };
let Some(n) = self.compound(kids, &gs) else {
if gs.hidden || !self.shown() {
self.hidden_unread.extend(unread);
} else {
self.unsupported.extend(unread);
}
return Ok(());
};
n
}
// A group with a clipping path (`q` … `Q`, or a `W` path in a group) is a clipping group.
@@ -124,6 +151,13 @@ impl<'a> Reader<'a> {
if compound_members {
self.compound_groups.insert(node.id);
}
if !unread.is_empty() {
if !node.visible || !self.shown() {
self.hidden_unread.extend(unread);
} else {
self.unread.push((node.id, unread));
}
}
self.add(node, opened_hidden || hidden)?;
if let Some(p) = painted {
self.clip_fills.insert(p.id);
@@ -173,6 +207,8 @@ impl<'a> Reader<'a> {
}
pub(super) fn end_layer(&mut self) -> Result<(), String> {
self.pending_unread_fill = false;
self.pending_unread_stroke = false;
let mut f = self.close("LB", |k| matches!(k, Kind::Layer(_)))?;
let state = self.layer_state.take();
// An `Xy` right after the layer's last mesh or blend, then `LB`: the layer's, not theirs.
@@ -208,15 +244,16 @@ impl<'a> Reader<'a> {
Ok(())
}
/// An operator the reader doesn't know: on hidden art it is only noted, on shown art the file
/// is read from its PDF part.
pub(super) fn note_unknown(&mut self, w: &str) {
/// An operator the reader doesn't know is scoped to its top-level object when possible.
pub(super) fn note_unknown(&mut self, w: &str, hidden: bool) {
let op: String = w.chars().take(24).collect();
if !self.shown() {
// A pattern definition or an owning mask can be on `%_` lines while still
// determining visible art. Unknown operators there must remain unread.
if (self.gs.hidden && self.pattern.is_none()) || (hidden && self.pattern.is_none() && !self.in_mask()) || !self.shown() {
if self.hidden_unread.len() < 16 {
self.hidden_unread.insert(format!("`{op}`"));
}
} else if self.unknown.len() < 16 {
} else if !self.defer_to_mask(&format!("`{op}`")) && !self.mark_unread(format!("`{op}`")) && self.unknown.len() < 16 {
self.unknown.insert(op);
}
}
@@ -234,6 +271,8 @@ impl<'a> Reader<'a> {
self.open(Kind::Obj(Box::new(Obj { ty, ..Obj::default() })), hidden)?;
// A dictionary's state starts afresh (styles set theirs inside).
self.gs = GState::default();
self.pending_unread_fill = false;
self.pending_unread_stroke = false;
Ok(())
}
@@ -244,6 +283,8 @@ impl<'a> Reader<'a> {
let (children, clip) = self.clip_first(f.children, &f.clips);
let art = self.new_node(NodeKind::Group { children, clip }, &f.gs);
self.gs = f.gs;
self.pending_unread_fill = f.pending_unread_fill;
self.pending_unread_stroke = f.pending_unread_stroke;
self.after_object = f.after;
self.mesh_ended = f.mesh_ended;
let Some(Frame { kind: Kind::Obj(o), .. }) = self.frames.last_mut() else {
@@ -252,6 +293,7 @@ impl<'a> Reader<'a> {
if o.art.replace(Arc::new(art)).is_some() {
return Err("a mask has more than one art block".into());
}
o.unread = f.unread;
Ok(())
}
@@ -261,13 +303,15 @@ impl<'a> Reader<'a> {
return Ok(());
}
let Some(f) = self.frames.pop() else { return Ok(()) };
let Frame { kind, children, base, hidden, gs, after, mesh_ended, state, .. } = f;
let Frame { kind, children, base, hidden, gs, pending_unread_fill, pending_unread_stroke, after, mesh_ended, state, .. } = f;
let Kind::Obj(mut o) = kind else { return Ok(()) };
obj::close(&mut o, &mut self.stack, base);
self.obj_hidden = hidden;
// Art made inside a dictionary (type's paths, a mask's art) isn't kept, and doesn't end what
// follows the object the dictionary is about: a mesh's state comes after its mask.
self.gs = gs;
self.pending_unread_fill = pending_unread_fill;
self.pending_unread_stroke = pending_unread_stroke;
if let Some(state) = state {
self.keep_state(state);
}
@@ -295,7 +339,7 @@ impl<'a> Reader<'a> {
}
"Document" => self.document(&o),
"AI11Text" => self.text_slot(&o)?,
"SymbolInstance" => self.unreadable("symbols"),
"SymbolInstance" => self.symbol_instance(&o)?,
"ForeignObject" => self.foreign_art(&o)?,
_ => {}
}
@@ -330,14 +374,95 @@ impl<'a> Reader<'a> {
/// Note something the data has that isn't read: an error on a layer that shows, left out of a
/// layer that doesn't.
pub(super) fn unreadable(&mut self, what: &str) {
if self.shown() {
self.unsupported.insert(what.into());
pub(super) fn unreadable(&mut self, what: &str, hidden: bool) {
if (!hidden || self.in_mask()) && !self.gs.hidden && self.shown() {
if !self.defer_to_mask(what) && !self.mark_unread(what.to_string()) {
self.unsupported.insert(what.into());
}
} else {
self.hidden_unread.insert(what.into());
}
}
/// Keep what an opacity mask's art doesn't read with the mask, until its dictionary says
/// whether the mask is enabled. Whether art of a mask is being read. Operators it doesn't know
/// are kept up to 16, as [`Reader::note_unknown`] keeps them.
fn defer_to_mask(&mut self, what: &str) -> bool {
let Some(f) = self.frames.iter_mut().rev().find(|f| matches!(f.kind, Kind::Mask)) else { return false };
let full = what.starts_with('`') && f.unread.iter().filter(|w| w.starts_with('`')).count() >= 16;
if !full && !f.unread.iter().any(|w| w == what) {
f.unread.push(what.into());
}
true
}
/// Mark the first object container below the innermost layer.
pub(super) fn mark_unread(&mut self, what: String) -> bool {
let Some(layer) = self.frames.iter().rposition(|f| matches!(f.kind, Kind::Layer(_))) else { return false };
let Some(frame) = self.frames.get_mut(layer + 1).filter(|f| matches!(f.kind, Kind::Group | Kind::Compound | Kind::Clip)) else {
return false;
};
if !frame.unread.contains(&what) {
frame.unread.push(what);
}
true
}
/// Stand in for one unread object with a box that the page can match.
pub(super) fn unread_box(&mut self, what: &str, bounds: super::Rect, hidden: bool) -> Result<Option<NodeId>, String> {
if (hidden && !self.in_mask()) || self.gs.hidden || !self.shown() {
self.hidden_unread.insert(what.into());
return Ok(None);
}
// A mask's art isn't an object of the layers: its object, or the file, is what isn't read.
if self.in_mask() {
self.unreadable(what, false);
return Ok(None);
}
let in_container = self
.frames
.iter()
.rposition(|f| matches!(f.kind, Kind::Layer(_)))
.is_some_and(|layer| self.frames.get(layer + 1).is_some_and(|f| matches!(f.kind, Kind::Group | Kind::Compound | Kind::Clip)));
if !super::art::sane(bounds) || bounds.area() <= 0.0 {
self.unsupported.insert(what.into());
return Ok(None);
}
let path = vectorcraft_geom::shapes::rectangle(bounds);
let gs = self.gs.clone();
let node = self.new_node(NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: false, guide: false }, &gs);
let id = node.id;
if in_container {
self.mark_unread(what.into());
}
self.add(node, hidden)?;
if !in_container {
self.unread.push((id, vec![what.into()]));
}
Ok(Some(id))
}
fn symbol_instance(&mut self, o: &Obj) -> Result<(), String> {
let bounds = o.get("SymbolRef").and_then(|v| v.iter().find_map(V::text)).and_then(|name| self.symbol_bounds.get(&name).copied());
let matrix = o
.get("Matrix")
.or_else(|| o.get("Transform"))
.map(|v| v.iter().find_map(|item| if let V::Arr(a) = item { Some(Self::nums(a)) } else { None }).unwrap_or_else(|| Self::nums(v)));
let box_at_canvas = match (bounds, matrix.as_deref()) {
(Some(b), Some([a, c, d, e, x, y])) if [*a, *c, *d, *e, *x, *y].iter().all(|n| n.is_finite()) => {
Some(self.space().transform_rect_bbox(super::Affine::new([*a, *c, *d, *e, *x, *y]).transform_rect_bbox(b)))
}
_ => None,
};
match box_at_canvas {
Some(b) => self.unread_box("symbols", b, self.obj_hidden).map(|_| ()),
None => {
self.unreadable("symbols", self.obj_hidden);
Ok(())
}
}
}
/// Drop the object just read when it is an envelope's own: a group on `%_` lines, before the
/// envelope's dictionary. It holds the mesh that shapes the envelope, or, while the envelope's
/// contents are being edited, those contents (the mesh then is in the dictionary); either way the
@@ -365,10 +490,15 @@ impl<'a> Reader<'a> {
}
/// Do the layers being read show?
fn shown(&self) -> bool {
pub(super) fn shown(&self) -> bool {
self.frames.iter().all(|f| !matches!(&f.kind, Kind::Layer(a) if !a.visible))
}
/// Is the art being read an opacity mask's? It is on `%_` lines and still shapes what shows.
pub(super) fn in_mask(&self) -> bool {
self.frames.iter().any(|f| matches!(f.kind, Kind::Mask))
}
/// A blend's record read: the blend's own art (the group of its key objects and spine, on `%_`
/// lines) and its drawn steps (the group after it) become one live blend, or, where they can't,
/// the drawn steps stay, without the blend's own art drawn over them (see [`super::blend`]).
+69 -7
View File
@@ -1,5 +1,5 @@
use super::art::{four, sane, two};
use super::types::{FLIP, Kind, MAX_HEX, MAX_PATTERNS, MAX_STACK, PatternHead, Place, Raster, SKIPPED, VERSIONED_UNENDED};
use super::types::{FLIP, Kind, MAX_HEX, MAX_PATTERNS, MAX_STACK, MAX_SUBREADERS, PatternHead, Place, Raster, SKIPPED, VERSIONED_UNENDED};
use super::{Arc, Node, PatternDef, Reader, Rect, Tok, V, marker};
impl<'a> Reader<'a> {
@@ -23,7 +23,7 @@ impl<'a> Reader<'a> {
// ---- comments --------------------------------------------------------------------------
pub(super) fn comment(&mut self, c: &[u8]) -> Result<(), String> {
pub(super) fn comment(&mut self, c: &[u8], hidden: bool) -> Result<(), String> {
let line = String::from_utf8_lossy(c);
let line = line.trim_end();
if let Some((len, hex)) = self.blend_record.as_mut() {
@@ -56,6 +56,11 @@ impl<'a> Reader<'a> {
self.define_pattern(section);
return Ok(());
}
if m == "BeginSymbol" {
let section = self.section("EndSymbol")?;
self.symbol_bounds(section);
return Ok(());
}
if m == "BeginArtStyles" {
let section = self.section("EndArtStyles")?;
self.styles = crate::import::ai::style::art_styles(section);
@@ -107,8 +112,8 @@ impl<'a> Reader<'a> {
self.place = Some(Place::default());
} else if line.starts_with("AI5_EndPlace") {
// One that `~` didn't end isn't read.
if self.place.take().is_some() {
self.unreadable("placed files");
if self.place.is_some() {
self.end_place(hidden)?;
}
} else if self.frames.is_empty()
&& let Some(v) = line.strip_prefix("%HiResBoundingBox:")
@@ -185,13 +190,15 @@ impl<'a> Reader<'a> {
/// A pattern's definition (`section`: what follows `%AI3_BeginPattern`), read by a reader of its
/// own into a pattern swatch, its art in the pattern's space (y down). A definition it can't
/// read whole (an operator it doesn't know, type, unbalanced groups, no tile) isn't kept: art
/// read whole (an operator it doesn't know, type, a pattern it doesn't have, unbalanced groups,
/// no tile) isn't kept: art
/// filled with it can't be read.
fn define_pattern(&mut self, section: &[u8]) {
if self.pattern_tiles.len() >= MAX_PATTERNS {
if self.pattern_tiles.len() >= MAX_PATTERNS || self.subreader_depth >= MAX_SUBREADERS {
return;
}
let mut sub = Reader::new(section);
sub.subreader_depth = self.subreader_depth + 1;
sub.pattern = Some(PatternHead::default());
// The gradients defined so far, lent (and those it defines kept).
sub.gradients = std::mem::take(&mut self.gradients);
@@ -200,7 +207,13 @@ impl<'a> Reader<'a> {
let read = sub.open(Kind::Group, false).and_then(|()| sub.run());
self.nodes = sub.nodes;
self.gradients = std::mem::take(&mut sub.gradients);
let whole = read.is_ok() && sub.unsupported.is_empty() && sub.unknown.is_empty() && sub.frames.len() == 1;
// Unread art in the definition, hidden or not, leaves the tile incomplete.
let whole = read.is_ok()
&& sub.unsupported.is_empty()
&& sub.unknown.is_empty()
&& sub.unread.is_empty()
&& sub.hidden_unread.is_empty()
&& sub.frames.len() == 1;
let (Some(head), Some(frame)) = (sub.pattern.take(), sub.frames.pop()) else { return };
let Some(tile) = head.tile.filter(|t| whole && sane(*t) && t.width() > 0.0 && t.height() > 0.0 && !frame.children.is_empty()) else {
return;
@@ -224,6 +237,34 @@ impl<'a> Reader<'a> {
}
}
/// Read a definition's box for an instance whose art must come from the page.
fn symbol_bounds(&mut self, section: &[u8]) {
if self.symbol_bounds.len() >= MAX_PATTERNS || self.subreader_depth >= MAX_SUBREADERS {
return;
}
let mut lex = super::Lexer::new(section);
let name = match lex.next_token().map(|t| t.tok) {
Some(Tok::Str(bytes)) => super::obj::text(&bytes),
_ => return,
};
let mut sub = Reader::new(section);
sub.subreader_depth = self.subreader_depth + 1;
sub.cropmarks = self.cropmarks;
sub.template = self.template;
sub.nodes = self.nodes;
let read = sub.open(Kind::Group, false).and_then(|()| sub.run());
self.nodes = sub.nodes;
if read.is_err() || !sub.unsupported.is_empty() || !sub.unknown.is_empty() || sub.frames.len() != 1 {
return;
}
let Some(frame) = sub.frames.pop() else { return };
let bounds = frame.children.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds()));
let Some(bounds) = bounds.map(|b| sub.space().inverse().transform_rect_bbox(b)) else { return };
if sane(bounds) && bounds.area() > 0.0 {
self.symbol_bounds.insert(name, bounds);
}
}
/// In a pattern's definition, the operands of its first operator start with its head,
/// `(name) llx lly urx ury`: taken off them.
pub(super) fn take_head(&mut self, mut vals: Vec<V>) -> Vec<V> {
@@ -237,3 +278,24 @@ impl<'a> Reader<'a> {
vals.split_off(at + 5)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deeply_nested_symbol_definitions_stop_at_subreader_limit() {
let mut definition = String::from("(Outer) 0 Ae\n");
for i in 0..300 {
definition.push_str(&format!("%AI14_BeginSymbol\n(S{i}) 0 Ae\n"));
}
definition.push_str("0 0 m\n4 0 L\n4 4 L\n0 4 L\nf\n");
for _ in 0..300 {
definition.push_str("%AI10_EndSymbol\n0 0 m\n4 0 L\n4 4 L\n0 4 L\nf\n");
}
let mut reader = Reader::new(b"");
reader.subreader_depth = MAX_SUBREADERS;
reader.symbol_bounds(definition.as_bytes());
assert!(reader.symbol_bounds.is_empty());
}
}
+14 -2
View File
@@ -243,10 +243,20 @@ impl<'a> Reader<'a> {
if let Some(n) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. })) {
n.extras_mut().mask = Some(Box::new(value));
} else if !on("Disabled") {
self.unreadable("opacity masks without an owning object");
self.unreadable("opacity masks without an owning object", false);
}
// What the art doesn't read matters only when the mask shapes what shows.
for what in &mask.unread {
if on("Disabled") {
self.hidden_unread.insert(what.clone());
} else if let Some(op) = what.strip_prefix('`').and_then(|w| w.strip_suffix('`')) {
self.note_unknown(op, false);
} else {
self.unreadable(what, false);
}
}
} else if mask.nums("Disabled").first().copied() != Some(1.0) {
self.unreadable("opacity masks without readable art");
self.unreadable("opacity masks without readable art", false);
}
}
let name = match o.text("AIArtName") {
@@ -453,6 +463,8 @@ impl<'a> Reader<'a> {
artboard,
template: self.template,
hidden_unread: self.hidden_unread,
unread: self.unread,
unread_elsewhere: self.unread_elsewhere,
slot_names: self.slot_names,
restyled: self.restyled,
placed_elsewhere: self.placed_elsewhere,
+22 -4
View File
@@ -80,7 +80,7 @@ impl<'a> Reader<'a> {
// Only `Bh` reads an undefined number. Anywhere else it would reach a point or a size, so
// the operator is one the reader doesn't know, as before.
if w != "Bh" && !self.frames.is_empty() && vals.iter().any(|v| matches!(v, V::Num(n) if n.is_nan())) {
self.note_unknown("--");
self.note_unknown("--", hidden);
return Ok(());
}
if let Some(d) = self.gradient_def.as_mut() {
@@ -303,7 +303,7 @@ impl<'a> Reader<'a> {
_ if IGNORED.contains(&w) => {}
// Before the art, the setup runs procedures of the printing format.
_ if self.frames.is_empty() => {}
_ => self.note_unknown(w),
_ => self.note_unknown(w, hidden),
}
Ok(())
}
@@ -340,22 +340,38 @@ impl<'a> Reader<'a> {
}
let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None });
let Some(tile) = name.as_deref().and_then(|n| self.pattern_tiles.get(n)).copied() else {
self.unreadable("pattern fills");
self.unread_pattern(w);
return;
};
let matrix = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { paint::affine(&Self::nums(a)) } else { None });
let Some(xf) = pattern_xf(self.space(), matrix, tile) else {
self.unreadable("pattern fills");
self.unread_pattern(w);
return;
};
let p = Paint::Pattern { pattern: name.unwrap_or_default(), xf };
if w == "p" {
self.pending_unread_fill = false;
self.gs.fill = p;
} else {
self.pending_unread_stroke = false;
self.gs.stroke = p;
}
}
fn unread_pattern(&mut self, w: &str) {
if self.shown() {
if w == "p" {
self.pending_unread_fill = true;
} else {
self.pending_unread_stroke = true;
}
}
// In a pattern's definition, only art painted with it makes the tile unreadable (see `paint`).
if self.pattern.is_none() && (!self.shown() || self.gs.hidden) {
self.hidden_unread.insert("pattern fills".into());
}
}
fn color(&mut self, w: &str, vals: &[V]) {
let Some((p, named)) = paint::color_op(w, vals) else { return };
// Registration (`Xs`, `Xz`) says nothing of the document's colour model.
@@ -373,9 +389,11 @@ impl<'a> Reader<'a> {
if w.ends_with(|c: char| c.is_ascii_lowercase()) {
self.gs.fill = p;
self.pending_fill = None;
self.pending_unread_fill = false;
} else {
self.gs.stroke = p;
self.pending_stroke = None;
self.pending_unread_stroke = false;
}
}
+8
View File
@@ -3,6 +3,9 @@ use super::{Affine, BlendMode, Dash, FillRule, Knockout, LayerColor, LineCap, Li
/// Deepest nesting of layers, groups and dictionaries.
pub(super) const MAX_DEPTH: usize = 256;
/// Most nested pattern and symbol readers on one call stack.
pub(super) const MAX_SUBREADERS: usize = 32;
/// Most operands on the stack.
pub(super) const MAX_STACK: usize = 1 << 20;
@@ -147,12 +150,17 @@ pub(super) struct Frame {
/// The state when it opened (a container's lock and visibility; a dictionary's state to go
/// back to).
pub(super) gs: GState,
/// Unresolved pattern paint saved by `q`, which restores graphics state at `Q`.
pub(super) pending_unread_fill: bool,
pub(super) pending_unread_stroke: bool,
/// The operand stack's height when it opened.
pub(super) base: usize,
/// The clipping paths' indexes among the children.
pub(super) clips: Vec<usize>,
/// A compound member group explicitly declares its subpaths' winding with `D`.
pub(super) compound_members: bool,
/// Unread kinds belonging to this top-level object.
pub(super) unread: Vec<String>,
/// A dictionary in the art: the transparency (`Xy`) its art set last, which stays after it.
pub(super) state: Option<(f32, BlendMode, bool, Knockout, bool)>,
}
+279 -9
View File
@@ -33,6 +33,13 @@ fn read_ok(body: &str) -> Structure {
}
}
fn assert_unread(r: &Structure, kind: &str) {
assert_eq!(r.unread.len(), 1);
let (id, kinds) = &r.unread[0];
assert_eq!(kinds, &[kind]);
assert!(r.doc.node(*id).is_some(), "the unread object has a placeholder");
}
#[test]
fn ruler_units_from_the_header_set_the_documents_units() {
let body = layer("");
@@ -286,18 +293,41 @@ fn a_pattern_it_cant_read_is_an_error_only_where_it_fills() {
let s = read_ok(&format!("{unread}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n"))));
assert!(s.doc.patterns.is_empty() && s.doc.swatches.iter().all(|w| !matches!(w.paint, Paint::Pattern { .. })));
for name in ["Old", "Odd", "Flat"] {
let e = read(
let r = read(
stream(&format!("{unread}{}", layer(&format!("({name}) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(0.0, 0.0, 1.0, 1.0)))))
.as_bytes(),
)
.unwrap_err();
assert!(e.contains("pattern fills"), "{name}: {e}");
.unwrap();
assert_unread(&r, "pattern fills");
}
// Type in a pattern isn't read either.
let typed = "%AI3_BeginPattern: (Words)\n(Words) 0 0 8 8\n%_/AI11Text :\n%_0 /StoryIndex ,\n%_;\nE\n%AI3_EndPattern\n";
assert!(read_ok(&format!("{typed}{}", layer(""))).doc.patterns.is_empty());
}
/// A pattern whose definition paints with a pattern it doesn't have isn't kept, on `%_` lines or
/// not: a fill with it is something the file has that isn't read.
#[test]
fn a_pattern_painting_with_an_unread_pattern_is_not_kept() {
for prefix in ["%_", ""] {
let tile = rect_path(0.0, 0.0, 8.0, 8.0).replace('\n', &format!("\n{prefix}"));
let def = format!(
"%AI3_BeginPattern: (Nest)\n(Nest) 0 0 8 8\n{prefix}(Missing) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{prefix}{tile}f\nE\n%AI3_EndPattern\n"
);
let s = read_ok(&format!("{def}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n"))));
assert!(s.doc.patterns.is_empty(), "{prefix:?}: {:?}", s.doc.patterns.iter().map(|p| &p.name).collect::<Vec<_>>());
let r = read(
stream(&format!("{def}{}", layer(&format!("(Nest) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(0.0, 0.0, 1.0, 1.0))))).as_bytes(),
)
.unwrap();
assert_unread(&r, "pattern fills");
// A color set before the tile is drawn replaces the missing pattern, and the pattern is kept.
let repainted = def.replace(&format!("{prefix}{tile}f"), &format!("{prefix}0 g\n{prefix}{tile}f"));
let s = read_ok(&format!("{repainted}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n"))));
assert!(s.doc.pattern("Nest").is_some(), "{prefix:?}");
}
}
#[test]
fn compound_paths_and_clipping() {
let art_ = format!(
@@ -982,6 +1012,42 @@ fn a_placed_file_on_commented_lines_is_read() {
assert_eq!((img.get_pixel(0, 0).0, img.get_pixel(1, 0).0), ([255, 0, 0, 255], [0, 127, 0, 128]), "its preview");
}
#[test]
fn hidden_placed_file_ended_by_comment_stays_hidden() {
let mut data = hide_placed(&placed(true, "[5 0 0 -5 0 200]", "/pics/photo.png", None, ""));
let end = data.windows(4).position(|w| w == b"%_~\r").unwrap();
data.drain(end..end + 4);
let r = read(&data).unwrap();
assert!(art(&r.doc).is_empty());
assert!(r.unread.is_empty(), "hidden placement has no shown placeholder");
assert!(r.hidden_unread.contains("placed files"));
}
#[test]
fn previewless_placement_records_box_corner_as_alternative() {
let mut data = placed(true, "[1 0 0 -1 -7970 8070]", "missing.png", None, "");
let at = data.windows(15).position(|w| w == b"%AI5_BeginLayer").unwrap();
data.splice(at..at, *b"%AI3_TemplateBox: 100 50 100 50\n");
let r = read(&data).unwrap();
assert_unread(&r, "placed files");
assert_eq!(r.unread_elsewhere.len(), 1);
assert_eq!(r.unread_elsewhere[0].0, r.unread[0].0);
assert_ne!(r.unread_elsewhere[0].1, r.doc.node(r.unread[0].0).unwrap().visual_bounds().unwrap());
}
/// A previewless locked placement keeps its edit lock on the page-art placeholder.
#[test]
fn previewless_placement_placeholder_inherits_lock() {
let mut data = placed(true, "[5 0 0 -5 0 200]", "missing.png", None, "");
let at = data.windows(15).position(|w| w == b"%AI5_BeginPlace").unwrap();
data.splice(at..at, *b"1 A\n");
let result = read(&data).unwrap();
assert_unread(&result, "placed files");
let node = result.doc.node(result.unread[0].0).unwrap();
assert!(node.locked);
assert!(node.visible);
}
#[test]
fn placed_files_are_linked_images_showing_their_preview() {
// A 2×1 preview (premultiplied ARGB, a padded row) of a file of 4×2 pixels.
@@ -1011,8 +1077,7 @@ fn placed_files_are_linked_images_showing_their_preview() {
let xf = pixels(&r.doc).1;
assert_eq!((xf * Point::ZERO, xf * Point::new(2.0, 1.0)), (Point::new(100.0, 50.0), Point::new(110.0, 30.0)));
// Without a preview it can read (none, cut short, not 32 bits, absurd sizes), without a path or
// a matrix, the file isn't read from its editing data.
// A placed file with a box but no usable preview is marked for page-art replacement.
let cut = &preview[..preview.len() - 6];
let huge = b"70000 70000 280000 32 17 \0\0\0\0";
let overflow = b"1 2 18446744073709551615 32 17 \0\0\0\0\0\0\0\0";
@@ -1027,8 +1092,13 @@ fn placed_files_are_linked_images_showing_their_preview() {
("[0 0 0 0 0 0]", "a.png", Some(&preview[..])),
("[1 2 3]", "a.png", Some(&preview[..])),
] {
let e = read(&placed(true, matrix, path, preview, "")).unwrap_err();
assert!(e.contains("placed files"), "{matrix} {path} {preview:?}: {e}");
let result = read(&placed(true, matrix, path, preview, ""));
if matrix == "[0 0 0 0 0 0]" || matrix == "[1 2 3]" {
let e = result.err().unwrap_or_else(|| panic!("expected missing bounds for {matrix} {path}"));
assert!(e.contains("placed files"), "{matrix} {path} {preview:?}: {e}");
} else {
assert_unread(&result.unwrap(), "placed files");
}
}
// On a layer that doesn't show, such a file is left out of it.
let r = read(&placed(false, "[5 0 0 -5 0 200]", "a.png", None, "")).unwrap();
@@ -1070,8 +1140,8 @@ fn text_objects_are_slots_named_after_their_story() {
fn what_it_doesnt_read_is_an_error_where_it_shows() {
let e = read(stream(&layer("1 2 frobnicate\n")).as_bytes()).unwrap_err();
assert!(e.contains("`frobnicate`"), "{e}");
let e = read(stream(&layer("(Jive) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n")).as_bytes()).unwrap_err();
assert!(e.contains("pattern fills"), "{e}");
let r = read(stream(&layer(&format!("(Jive) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(0.0, 0.0, 1.0, 1.0)))).as_bytes()).unwrap();
assert_unread(&r, "pattern fills");
let e = read(stream(&layer("/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n")).as_bytes()).unwrap_err();
assert!(e.contains("symbols"), "{e}");
// A mask with no owner must still be reported (#2004).
@@ -1093,6 +1163,206 @@ fn what_it_doesnt_read_is_an_error_where_it_shows() {
read_ok(&format!("Adobe_level2_AI5 /initialize get exec\n{}", layer("")));
}
#[test]
fn unresolved_pattern_reset_before_group_path_keeps_live_art() {
for (operator, draw, reset) in [("p", "f", "0 0 0 1 k"), ("P", "S", "0 0 0 1 K")] {
let body = format!("u\n(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n{reset}\n{}{draw}\nU\n", rect_path(0.0, 0.0, 4.0, 4.0),);
let result = read_ok(&layer(&body));
assert!(result.unread.is_empty(), "{operator}: {:?}", result.unread);
let children = art(&result.doc);
assert_eq!(children.len(), 1, "{operator}: group count");
let group_art = children[0].children().expect("group children");
assert_eq!(group_art.len(), 1, "{operator}: live path count");
assert!(matches!(group_art[0].kind, NodeKind::Path { .. }));
}
}
#[test]
fn unresolved_pattern_in_dictionary_does_not_mark_following_path_unread() {
for (operator, draw) in [("p", "f"), ("P", "S")] {
let body = format!("/Unused :\n(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n;\n{}{draw}\n", rect_path(0.0, 0.0, 4.0, 4.0),);
let result = read_ok(&layer(&body));
assert!(result.unread.is_empty(), "{operator}: {:?}", result.unread);
let children = art(&result.doc);
assert_eq!(children.len(), 1, "{operator}: live path count");
assert!(matches!(children[0].kind, NodeKind::Path { .. }));
}
}
#[test]
fn color_in_dictionary_does_not_clear_outer_unresolved_pattern() {
for (operator, draw, color) in [("p", "f", "0 0 0 1 k"), ("P", "S", "0 0 0 1 K")] {
let body = format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n/Unused :\n{color}\n;\n{}{draw}\n", rect_path(0.0, 0.0, 4.0, 4.0),);
let result = read_ok(&layer(&body));
assert_unread(&result, "pattern fills");
assert_eq!(art(&result.doc).len(), 1, "{operator}: unread path placeholder");
}
}
#[test]
fn gradient_instance_after_unresolved_pattern_is_read() {
let defs =
"%AI5_BeginGradient: (G)\n(G) 0 2 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 0 0 2 1 6 50 0 Bs\n%_0 0 0 0 0 0 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
for (operator, flag, draw) in [("p", "", "f"), ("P", "1 ", "S")] {
let body = format!(
"(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n{}{draw}\n{}{flag}Bb\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n10 0 0 10 100 50 Xm\n{draw}\n0 BB\n",
rect_path(0.0, 0.0, 4.0, 4.0),
rect_path(10.0, 0.0, 14.0, 4.0),
);
let result = read(stream(&format!("{defs}{}", layer(&body))).as_bytes()).unwrap();
assert_unread(&result, "pattern fills");
let a = art(&result.doc);
assert_eq!(a.len(), 2, "{operator}");
assert_eq!(result.unread[0].0, a[0].id, "{operator}: only the pattern's path is unread");
}
}
#[test]
fn unresolved_pattern_in_mask_art_stays_unread() {
let mask_path = rect_path(0.0, 0.0, 4.0, 4.0).lines().map(|line| format!("%_{line}\n")).collect::<String>();
let patterned = format!("%_(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{mask_path}%_f\n");
// A placed file with no preview, and a symbol whose definition the file doesn't have.
let placed = "%_%AI5_BeginPlace\n%_%%FileType: 1\n%_[5 0 0 -5 0 200] 10 20 14 22 4 4 0\n%_(missing.png)`\n%_%%IncludeFile:missing.png\n%_~\n%_%AI5_EndPlace\n";
let symbol = "%_/SymbolInstance :\n%_(Absent) /SymbolRef ,\n%_;\n";
for (art_, kind) in [
(patterned.as_str(), "pattern fills"),
("%_/Mesh X!\n%_/End X!\n", "gradient meshes it can't read"),
(placed, "placed files"),
(symbol, "symbols"),
("%_1 2 frobnicate\n", "`frobnicate`"),
] {
let mask = format!(
"%_/ArtDictionary :\n%_/Mask :\n%_X=\n{art_}%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_1 /Bool (Linked) ,\n%_; (AI9_Mask) ,\n%_;\n"
);
let masked = format!("{RED}{}f\n{mask}", rect_path(0.0, 0.0, 4.0, 4.0));
let e = read(stream(&layer(&masked)).as_bytes()).unwrap_err();
assert!(e.contains(kind), "{e}");
// In a group, the group is the unread object.
let result = read_ok(&layer(&format!("u\n{masked}U\n")));
assert_unread(&result, kind);
assert!(result.hidden_unread.is_empty(), "{kind}: {:?}", result.hidden_unread);
// A disabled mask doesn't change how its object looks: what it holds is left out.
let disabled = masked.replace("%_1 /Bool (Linked) ,\n", "%_1 /Bool (Linked) ,\n%_1 /Bool (Disabled) ,\n");
for body in [disabled.clone(), format!("u\n{disabled}U\n")] {
let result = read_ok(&layer(&body));
assert!(result.unread.is_empty(), "{kind}: {:?}", result.unread);
assert!(result.hidden_unread.contains(kind), "{kind}: {:?}", result.hidden_unread);
}
}
// Operators a mask's art doesn't know are kept up to 16.
let ops = (0..40).map(|i| format!("%_op{i}\n")).collect::<String>();
let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{ops}%_X+\n%_(Art) ,\n%_1 /Bool (Linked) ,\n%_; (AI9_Mask) ,\n%_;\n");
let result = read_ok(&layer(&format!("u\n{RED}{}f\n{mask}U\n", rect_path(0.0, 0.0, 4.0, 4.0))));
assert_eq!(result.unread.len(), 1);
assert_eq!(result.unread[0].1.len(), 16);
}
#[test]
fn hidden_unread_art_beside_drawn_look_needs_no_page_replacement() {
let hidden_path = rect_path(0.0, 0.0, 4.0, 4.0).lines().map(|line| format!("%_{line}\n")).collect::<String>();
for (operator, draw, reset) in [("p", "f", "0 0 0 1 k"), ("P", "S", "0 0 0 1 K")] {
let body = format!(
"u\n%_(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n{hidden_path}%_{draw}\n{reset}\n{}{draw}\nU\n",
rect_path(10.0, 0.0, 14.0, 4.0),
);
let result = read_ok(&layer(&body));
assert!(result.unread.is_empty(), "{operator}: {:?}", result.unread);
assert!(result.hidden_unread.contains("pattern fills"));
let children = art(&result.doc);
let group_art = children[0].children().expect("group children");
assert_eq!(group_art.len(), 2);
assert!(matches!(group_art[1].kind, NodeKind::Path { .. }));
}
}
#[test]
fn unknown_operator_on_hidden_line_does_not_mark_drawn_group_unread() {
let body = format!("u\n%_1 2 frobnicate\n{RED}{}f\nU\n", rect_path(10.0, 0.0, 14.0, 4.0));
let result = read_ok(&layer(&body));
assert!(result.unread.is_empty(), "{:?}", result.unread);
assert!(result.hidden_unread.contains("`frobnicate`"));
assert_eq!(art(&result.doc)[0].children().expect("group children").len(), 1);
}
/// A commented closing delimiter does not hide a visible group's unread content.
#[test]
fn visible_group_closed_on_hidden_line_stays_unread() {
let body = format!("u\n1 2 frobnicate\n{RED}{}f\n%_U\n", rect_path(10.0, 0.0, 14.0, 4.0));
let result = read_ok(&layer(&body));
assert_eq!(result.unread.len(), 1);
assert!(result.unread[0].1.contains(&"`frobnicate`".to_string()));
assert!(!result.hidden_unread.contains("`frobnicate`"));
}
/// `Xw` hides unread objects even when their operators are on ordinary lines.
#[test]
fn xw_hidden_unread_keeps_visible_group_sibling() {
for unread in
["1 2 frobnicate\n", "(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n0 0 m\n4 0 L\n4 4 L\nf\n", "/SymbolInstance :\n(Absent) /SymbolRef ,\n;\n"]
{
let body = format!("u\n1 Xw\n{unread}0 Xw\n0 0 0 1 k\n{}f\nU\n", rect_path(10.0, 0.0, 14.0, 4.0));
let result = read_ok(&layer(&body));
assert!(result.unread.is_empty(), "{unread}: {:?}", result.unread);
assert!(!result.hidden_unread.is_empty(), "{unread}");
let children = art(&result.doc);
let group_art = children[0].children().expect("group children");
assert_eq!(group_art.iter().filter(|node| node.visible).count(), 1, "{unread}");
}
let mut data = placed(true, "[5 0 0 -5 0 200]", "missing.png", None, "0 Xw\n");
let at = data.windows(15).position(|w| w == b"%AI5_BeginPlace").unwrap();
data.splice(at..at, *b"1 Xw\n");
let result = read(&data).unwrap();
assert!(result.unread.is_empty());
assert!(result.hidden_unread.contains("placed files"));
}
#[test]
fn hidden_unread_symbol_does_not_mark_drawn_group_unread() {
let body = format!("u\n%_/SymbolInstance :\n%_(Absent) /SymbolRef ,\n%_;\n{RED}{}f\nU\n", rect_path(10.0, 0.0, 14.0, 4.0));
let result = read_ok(&layer(&body));
assert!(result.unread.is_empty(), "{:?}", result.unread);
assert!(result.hidden_unread.contains("symbols"));
assert_eq!(art(&result.doc)[0].children().expect("group children").len(), 1);
}
#[test]
fn unresolved_pattern_paint_persists_past_plain_group_until_replaced() {
for (operator, draw, reset) in [("p", "f", "0 0 0 1 k"), ("P", "S", "0 0 0 1 K")] {
let body = format!(
"u\n(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n{}{draw}\nU\n{}{draw}\n{reset}\n{}{draw}\n",
rect_path(0.0, 0.0, 4.0, 4.0),
rect_path(10.0, 0.0, 14.0, 4.0),
rect_path(20.0, 0.0, 24.0, 4.0),
);
let result = read_ok(&layer(&body));
assert_eq!(result.unread.len(), 2, "{operator}: {:?}", result.unread);
assert_eq!(art(&result.doc).len(), 3);
}
}
#[test]
fn unresolved_pattern_paint_inside_clip_restores_at_q() {
for (operator, draw, reset) in [("p", "f", "0 0 0 1 k"), ("P", "S", "0 0 0 1 K")] {
let body = format!(
"q\n(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n{}{draw}\nQ\n{}{draw}\n",
rect_path(0.0, 0.0, 4.0, 4.0),
rect_path(10.0, 0.0, 14.0, 4.0),
);
let result = read_ok(&layer(&body));
assert_eq!(result.unread.len(), 1, "{operator}: {:?}", result.unread);
assert_eq!(art(&result.doc).len(), 2);
let body = format!(
"(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\nq\n{reset}\n{}{draw}\nQ\n{}{draw}\n",
rect_path(0.0, 0.0, 4.0, 4.0),
rect_path(10.0, 0.0, 14.0, 4.0),
);
let result = read_ok(&layer(&body));
assert_eq!(result.unread.len(), 1, "{operator}: {:?}", result.unread);
assert_eq!(result.unread[0].0, art(&result.doc)[1].id);
}
}
#[test]
fn sections_it_skips() {
let body = "%AI5_Begin_NonPrinting\n%AI14_BeginSymbol\n0 Ae\nu\nfrobnicate\n%AI10_EndSymbol\n%AI3_BeginPattern: (P)\nU\nU\n%AI3_EndPattern\n%AI5_End_NonPrinting--\n\
+2 -2
View File
@@ -90,7 +90,7 @@ mod tests {
ring(&mut layers, Some(MADE), red, 6.0, square);
ring(&mut layers, Some(MADE), red, 2.0, Rect::new(40.0, 40.0, 60.0, 60.0));
let rect = page.artboards[0].rect;
let frame = Frame { page: &page, page_rect: rect, rect, whole: false };
let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] };
let mut view = PageView::new(&frame);
assert_eq!(prune_unseen(&frame, &mut view, &mut layers, false), 1, "only the ring the page doesn't draw");
let widths: Vec<f64> = layers.layers[0].children().unwrap().iter().filter_map(|n| n.appearance.stroke().map(|s| s.width)).collect();
@@ -192,7 +192,7 @@ mod tests {
let a = comparison_render(&mut renderer, &page, rect, 0.4, 1);
let b = comparison_render(&mut vectorcraft_render::Renderer::new(), &vector, rect, 0.4, 1);
assert!(compared(&a, &b, &[], &[]).solid > MAX_SOLID_DIFFERENCE, "the old point sampling falsely refused this page");
let frame = Frame { page: &page, page_rect: rect, rect, whole: false };
let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] };
let mut view = PageView::new(&frame);
assert!(compare(&frame, &mut view, &vector, false).is_ok());
assert!(compare(&frame, &mut view, &Document::new(1000.0, 500.0), false).is_err(), "missing artwork remains an error");
+749 -5
View File
@@ -26,6 +26,8 @@ pub(super) struct Frame<'a> {
/// The page holds all the art that prints (an EPS's page is its art's box, unless it was
/// saved as its artboard): the layers print nothing outside it.
pub(super) whole: bool,
/// The PDF page areas and their moves into the editing document.
pub(super) pages: Vec<(Rect, Affine)>,
}
/// The document of the editing copy `data`, with the text (and strokes round it) of `visible`, the
@@ -57,7 +59,20 @@ pub(super) fn build_from(
trust: bool,
unit: f64,
) -> Result<(Document, Vec<String>), String> {
let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, restyled, placed_elsewhere, warnings } = structure;
let Structure {
mut doc,
to_doc,
bbox,
artboard,
template,
hidden_unread,
unread,
unread_elsewhere,
slot_names,
restyled,
placed_elsewhere,
warnings,
} = structure;
// Editing data in the units of the page's user space ([`in_units`]) is compared with the page
// scaled down to those units, and the document read from it is scaled up by `unit`.
let board = artboard.map(|a| to_doc.transform_rect_bbox(a));
@@ -132,6 +147,15 @@ pub(super) fn build_from(
// goes where the layers have that artboard.
let pages: Vec<Rect> = visible.artboards.iter().map(|a| a.rect).collect();
let boards = page_boards(&doc.artboards, first, &pages);
let page_moves: Vec<(Rect, Affine)> = pages
.iter()
.enumerate()
.map(|(i, page)| {
let move_to_doc =
boards.and_then(|boards| boards.get(i)).map(|board| Affine::translate(board.rect.origin() - page.origin())).unwrap_or(shift);
(*page, move_to_doc)
})
.collect();
// The page with most of an object, the first for what is on none.
let page_of = |object: &[Arc<Node>]| {
extent(object).and_then(|r| {
@@ -167,7 +191,7 @@ pub(super) fn build_from(
a.rect = rect;
}
}
frame = Some(Frame { page: visible, page_rect, rect, whole });
frame = Some(Frame { page: visible, page_rect, rect, whole, pages: page_moves });
}
let mut infos = vec![];
collect_slots(&doc.layers, true, &mut infos);
@@ -180,6 +204,9 @@ pub(super) fn build_from(
if outlined && infos.iter().any(|i| i.shown) {
return Err("its type is to come in as outlines, which only its page has".into());
}
// The page's type inside an unread object's box comes in with the page's art that replaces the
// object (see [`replace_unread`]), not in a text object.
let unread_type = take_unread_type(&doc, frame.as_ref(), &unread, &unread_elsewhere, &mut shown);
// The page's type that needs no text object of the layers: the layers' own legacy text, or
// placed files that show it.
let as_outlines = page_type_elsewhere(infos.is_empty(), infos.iter().all(|i| !i.shown), &mut shown, &mut hidden, &doc, &placed_elsewhere)?;
@@ -214,8 +241,22 @@ pub(super) fn build_from(
let mut made = vec![];
fill_slots(&mut doc, &mut layers, &mut content.into_iter(), &slot_names, &mut empty, &mut made, &mut vec![]);
doc.layers = layers;
// A story in several frames is threaded type: its frames in order (the first holds the story).
made.sort();
let threaded_frames: BTreeSet<NodeId> =
made.chunk_by(|a, b| a.0 == b.0).filter(|thread| thread.len() > 1).flatten().map(|(_, _, id)| *id).collect();
if !unread.is_empty() {
let Some(frame) = frame.as_ref() else { return Err(unread_error(&unread)) };
let mut copied = BTreeSet::new();
let kinds = replace_unread(&mut doc, frame, &unread, &unread_elsewhere, &threaded_frames, &mut copied)?;
// With the text objects filled, the page art chosen can differ from the first try's: the
// page's type set aside for it, and no other, must be in it.
let all_copied = |o: &Vec<Arc<Node>>| !o.is_empty() && o.iter().all(|n| copied.contains(&n.id));
if !unread_type.iter().all(all_copied) || shown.iter().any(all_copied) {
return Err("the page's type in an unread object's box isn't in its page art".into());
}
notes.push(format!("{}: they came in as the page's art", kinds.join(", ")));
}
// A story in several frames is threaded type: its frames in order (the first holds the story).
for thread in made.chunk_by(|a, b| a.0 == b.0) {
if thread.len() > 1 {
doc.text_threads.push(thread.iter().map(|(_, _, id)| *id).collect());
@@ -285,6 +326,406 @@ pub(super) fn build_from(
Ok((doc, notes))
}
/// A visible page node, with its source order and subtree range.
struct PageArt {
node: Arc<Node>,
bounds: Rect,
page: usize,
end: usize,
stateful_ancestor: bool,
}
const ART_MARGIN: f64 = 1.0;
fn inside(art: Rect, bounds: Rect) -> bool {
art.x0 >= bounds.x0 - ART_MARGIN && art.y0 >= bounds.y0 - ART_MARGIN && art.x1 <= bounds.x1 + ART_MARGIN && art.y1 <= bounds.y1 + ART_MARGIN
}
/// Pick the page holding the most of a box. The first page wins a tie.
fn page_for(bounds: Rect, pages: &[Rect]) -> usize {
let mut best = (0, 0.0);
for (i, page) in pages.iter().enumerate() {
let area = page.intersect(bounds).area().max(0.0);
if area > best.1 {
best = (i, area);
}
}
best.0
}
fn collect_live(node: &Node, unread: &BTreeSet<NodeId>, live: &mut Vec<Rect>) {
if !node.visible || unread.contains(&node.id) {
return;
}
if let Some(children) = node.children() {
for child in children {
collect_live(child, unread, live);
}
} else if let Some(bounds) = node.visual_bounds() {
live.push(bounds);
}
}
fn page_node_has_drawing_state(node: &Node) -> bool {
node.clips()
|| matches!(node.kind, NodeKind::Path { clipping: true, .. })
|| node.opacity < 1.0
|| node.blend != super::BlendMode::Normal
|| node.isolate
|| node.knockout != vectorcraft_doc::Knockout::Neutral
|| node.extras().mask.is_some()
|| !node.appearance.items.is_empty()
|| !node.appearance.effects.is_empty()
}
fn index_page(node: &Arc<Node>, pages: &[Rect], moves: &[Affine], art: &mut Vec<PageArt>, stateful_ancestor: bool) {
if !node.visible {
return;
}
let start = art.len();
let mut indexed = false;
if let Some(bounds) = node.visual_bounds() {
let page = page_for(bounds, pages);
if let Some(shift) = moves.get(page) {
art.push(PageArt { node: Arc::clone(node), bounds: shift.transform_rect_bbox(bounds), page, end: start + 1, stateful_ancestor });
indexed = true;
}
}
if let Some(children) = node.children() {
for child in children {
index_page(child, pages, moves, art, stateful_ancestor || page_node_has_drawing_state(node));
}
}
let end = art.len();
if indexed && let Some(entry) = art.get_mut(start) {
entry.end = end;
}
}
/// Node IDs belong to the importing document, not to the pattern's drawing.
fn same_pattern_art(existing: &vectorcraft_doc::PatternDef, source: &vectorcraft_doc::PatternDef) -> bool {
fn clear_ids(node: &mut Node) {
node.id = NodeId(0);
if let Some(children) = node.children_mut() {
for child in children {
clear_ids(Arc::make_mut(child));
}
}
if let Some(mask) = &mut node.extras_mut().mask {
clear_ids(Arc::make_mut(&mut mask.art));
}
}
let mut a = existing.clone();
let mut b = source.clone();
for pattern in [&mut a, &mut b] {
for node in &mut pattern.art {
clear_ids(Arc::make_mut(node));
}
}
a == b
}
/// Bring document resources used by page art across before reassigning its node IDs.
fn copy_page_resources(doc: &mut Document, page: &Document, nodes: &[&Node]) -> Result<(), String> {
fn collect(node: &Node, images: &mut BTreeSet<String>, patterns: &mut BTreeSet<String>, swatches: &mut BTreeSet<String>) {
if let NodeKind::Image(im) = &node.kind {
images.insert(im.key.clone());
}
for item in &node.appearance.items {
match item.paint() {
vectorcraft_color::Paint::Solid { swatch: Some(name), .. } => {
swatches.insert(name.clone());
}
vectorcraft_color::Paint::Gradient(gradient) => {
if let Some(name) = &gradient.swatch {
swatches.insert(name.clone());
}
for stop in &gradient.gradient.stops {
if let Some(name) = &stop.swatch {
swatches.insert(name.clone());
}
}
}
vectorcraft_color::Paint::Pattern { pattern, .. } => {
patterns.insert(pattern.clone());
}
_ => {}
}
}
if let Some(mask) = &node.extras().mask {
collect(&mask.art, images, patterns, swatches);
}
for child in node.children().into_iter().flatten() {
collect(child, images, patterns, swatches);
}
}
let (mut images, mut patterns, mut swatches) = (BTreeSet::new(), BTreeSet::new(), BTreeSet::new());
for node in nodes {
collect(node, &mut images, &mut patterns, &mut swatches);
}
let mut copied = BTreeSet::new();
while let Some(name) = patterns.pop_first() {
if !copied.insert(name.clone()) {
continue;
}
let Some(source) = page.pattern(&name) else { return Err(format!("page art is missing pattern {name}")) };
if let Some(existing) = doc.pattern(&name) {
if !same_pattern_art(existing, source) {
return Err(format!("page art conflicts with pattern {name}"));
}
} else {
for node in &source.art {
collect(node, &mut images, &mut patterns, &mut swatches);
}
let mut pattern = source.clone();
pattern.art = pattern.art.iter().map(|node| Arc::new(reid_page_art(doc, node))).collect();
doc.patterns.push(pattern);
}
}
for key in images {
let Some(blob) = page.images.get(&key) else { return Err(format!("page art is missing image data for {key}")) };
if doc.images.get(&key).is_some_and(|existing| existing.bytes != blob.bytes) {
return Err(format!("page art conflicts with image data for {key}"));
}
doc.images.entry(key).or_insert_with(|| blob.clone());
}
for name in swatches {
let Some(source) = page.swatch(&name) else { return Err(format!("page art is missing swatch {name}")) };
if let Some(existing) = doc.swatch(&name) {
if existing != source {
return Err(format!("page art conflicts with swatch {name}"));
}
} else {
doc.swatches.push(source.clone());
}
}
Ok(())
}
/// The PDF importer keeps group transparency on a group, or folds it into its only child.
/// If the editing object's state cannot be located in that art, use the PDF page as a whole.
/// Every selected page object must carry the state, on itself or down a chain of only children.
fn page_carries_state(editing: &Node, page: &[&Node]) -> bool {
fn carried(node: &Node, matches: &dyn Fn(&Node) -> bool) -> bool {
matches(node)
|| match node.children().map(Vec::as_slice) {
Some([only]) => carried(only, matches),
_ => false,
}
}
let has = |matches: &dyn Fn(&Node) -> bool| !page.is_empty() && page.iter().all(|node| carried(node, matches));
let opacity = editing.opacity >= 0.999 || has(&|node: &Node| (node.opacity - editing.opacity).abs() < 0.001);
let blend = editing.blend == super::BlendMode::Normal || has(&|node: &Node| node.blend == editing.blend);
let isolate = !editing.isolate || has(&|node: &Node| node.isolate);
let knockout = matches!(editing.knockout, vectorcraft_doc::Knockout::Neutral | vectorcraft_doc::Knockout::Off)
|| has(&|node: &Node| node.knockout == editing.knockout);
let appearance = !matches!(editing.kind, NodeKind::Group { .. }) || *editing.appearance == vectorcraft_doc::Appearance::default();
opacity && blend && isolate && knockout && appearance
}
/// The error for unread objects with no page art to use: what isn't read, as the reader names it.
fn unread_error(unread: &[(NodeId, Vec<String>)]) -> String {
let kinds: BTreeSet<&str> = unread.iter().flat_map(|(_, kinds)| kinds.iter().map(String::as_str)).collect();
let (ops, other): (Vec<&str>, Vec<&str>) = kinds.into_iter().partition(|k| k.starts_with('`'));
let mut list: BTreeSet<String> = other.into_iter().map(String::from).collect();
if !ops.is_empty() {
list.insert(format!("operators VectorCraft doesn't know ({})", ops.join(", ")));
}
let list: Vec<&str> = list.iter().map(String::as_str).collect();
format!("it has {}, which VectorCraft doesn't read from it yet", ai::join_and(&list))
}
/// The page's text objects in `shown` that the page objects replacing `doc`'s unread objects hold
/// (see [`replace_unread`], tried on a copy of `doc` before its text objects are filled), taken out
/// of `shown`.
fn take_unread_type(
doc: &Document,
frame: Option<&Frame<'_>>,
unread: &[(NodeId, Vec<String>)],
elsewhere: &[(NodeId, Rect)],
shown: &mut Vec<Vec<Arc<Node>>>,
) -> Vec<Vec<Arc<Node>>> {
let Some(frame) = frame.filter(|_| !unread.is_empty() && !shown.is_empty()) else { return vec![] };
let mut copied = BTreeSet::new();
if replace_unread(&mut doc.clone(), frame, unread, elsewhere, &BTreeSet::new(), &mut copied).is_err() {
return vec![];
}
let (taken, kept) = std::mem::take(shown).into_iter().partition(|o| !o.is_empty() && o.iter().all(|n| copied.contains(&n.id)));
*shown = kept;
taken
}
/// Replace each unread top-level object with painted page objects inside its readable bounds.
/// `copied` gets the ids of the page's nodes copied.
fn replace_unread(
doc: &mut Document,
frame: &Frame<'_>,
unread: &[(NodeId, Vec<String>)],
elsewhere: &[(NodeId, Rect)],
threaded_frames: &BTreeSet<NodeId>,
copied: &mut BTreeSet<NodeId>,
) -> Result<Vec<String>, String> {
fn holds_threaded_frame(node: &Node, frames: &BTreeSet<NodeId>) -> bool {
frames.contains(&node.id) || node.children().is_some_and(|children| children.iter().any(|child| holds_threaded_frame(child, frames)))
}
fn subtree(node: &Node, ids: &mut BTreeSet<NodeId>) {
ids.insert(node.id);
for child in node.children().into_iter().flatten() {
subtree(child, ids);
}
}
let unread_ids: BTreeSet<NodeId> = unread.iter().map(|(id, _)| *id).collect();
let mut live = Vec::new();
for layer in &doc.layers {
collect_live(layer, &unread_ids, &mut live);
}
live.sort_by(|a, b| a.x0.total_cmp(&b.x0));
let source_pages: Vec<Rect> = frame.pages.iter().map(|(page, _)| *page).collect();
let moves: Vec<Affine> = frame.pages.iter().map(|(_, shift)| *shift).collect();
let destination_pages: Vec<Rect> = frame.pages.iter().map(|(page, shift)| shift.transform_rect_bbox(*page)).collect();
let mut art = Vec::new();
for layer in &frame.page.layers {
if layer.visible
&& let Some(children) = layer.children()
{
for child in children {
index_page(child, &source_pages, &moves, &mut art, false);
}
}
}
let mut by_x: Vec<usize> = (0..art.len()).collect();
by_x.sort_by(|a, b| art[*a].bounds.x0.total_cmp(&art[*b].bounds.x0));
let mut claimed = std::collections::BTreeMap::<usize, usize>::new();
let mut counts = std::collections::BTreeMap::<String, usize>::new();
for (id, kinds) in unread {
let Some((bounds, name, visible, locked, editing)) =
doc.node(*id).and_then(|n| n.visual_bounds().map(|b| (b, n.name.clone(), n.visible, n.locked, n.clone())))
else {
return Err("an unread object has no readable bounds".into());
};
if !bounds.area().is_finite() || bounds.area() <= 0.0 {
return Err("an unread object has no readable bounds".into());
}
let mut selected = Vec::new();
let mut page = 0;
let mut first_rejection = None;
'candidate: for candidate in std::iter::once(bounds).chain(elsewhere.iter().filter(|(other, _)| *other == *id).map(|(_, b)| *b)) {
let live_start = live.partition_point(|b| b.x0 < candidate.x0 - ART_MARGIN);
let live_end = live.partition_point(|b| b.x0 <= candidate.x1 + ART_MARGIN);
if live[live_start..live_end].iter().any(|art| inside(*art, candidate)) {
first_rejection.get_or_insert("an unread object's bounds also contain live art");
continue;
}
// Each page has its own copy of art across artboards: one page's art can't stand in for it.
if destination_pages.iter().filter(|page| page.intersect(candidate).area() > 0.0).count() > 1 {
first_rejection.get_or_insert("an unread object lies on more than one artboard");
continue;
}
let candidate_page = page_for(candidate, &destination_pages);
// An artboard that has no page (a PDF part saved with some of the artboards) has no art
// on the page for the object's part on it.
if let Some(page) = destination_pages.get(candidate_page)
&& doc.artboards.iter().map(|a| a.rect.intersect(candidate)).any(|part| part.area() > page.intersect(part).area().max(0.0))
{
first_rejection.get_or_insert("an unread object lies on an artboard that has no page");
continue;
}
let start = by_x.partition_point(|i| art[*i].bounds.x0 < candidate.x0 - ART_MARGIN);
let end = by_x.partition_point(|i| art[*i].bounds.x0 <= candidate.x1 + ART_MARGIN);
let mut candidates: Vec<usize> =
by_x[start..end].iter().copied().filter(|i| art[*i].page == candidate_page && inside(art[*i].bounds, candidate)).collect();
candidates.sort_unstable();
let mut covered_until = 0;
let mut candidate_selected = Vec::new();
for i in candidates {
if i < covered_until || matches!(art[i].node.kind, NodeKind::Path { clipping: true, .. }) {
continue;
}
if art[i].stateful_ancestor {
first_rejection.get_or_insert("an unread object's page art depends on a clipping or appearance ancestor");
continue 'candidate;
}
covered_until = art[i].end;
candidate_selected.push(i);
}
if !candidate_selected.is_empty()
&& !page_carries_state(&editing, &candidate_selected.iter().map(|&i| art[i].node.as_ref()).collect::<Vec<_>>())
{
first_rejection.get_or_insert("an unread object's appearance is not identifiable in the page art");
continue;
}
if candidate_selected.iter().any(|&i| claimed.range(..art[i].end).next_back().is_some_and(|(_, end)| *end > i)) {
first_rejection.get_or_insert("one page object matches more than one unread object");
continue;
}
if !candidate_selected.is_empty() {
selected = candidate_selected;
page = candidate_page;
break;
}
}
if selected.is_empty() {
return Err(first_rejection.unwrap_or("an unread object's page art wasn't found in its bounds").into());
}
if holds_threaded_frame(&editing, threaded_frames) {
return Err("an unread object holds a frame of threaded type".into());
}
copy_page_resources(doc, frame.page, &selected.iter().map(|&i| art[i].node.as_ref()).collect::<Vec<_>>())?;
let mut found = Vec::new();
for &i in &selected {
let mut copy = Node::clone(&art[i].node);
if let Some(shift) = moves.get(page) {
copy.transform(*shift, false);
}
found.push(Arc::new(reid_page_art(doc, &copy)));
claimed.insert(i, art[i].end);
subtree(&art[i].node, copied);
}
let mut replacement = Node::group(*id, found);
replacement.name = name;
replacement.visible = visible;
replacement.locked = locked;
let Some(target) = doc.node_mut(*id) else { return Err("an unread object is missing from its layer".into()) };
*target = replacement;
for kind in kinds {
*counts.entry(kind.clone()).or_default() += 1;
}
}
Ok(counts.into_iter().map(|(kind, count)| format!("{kind} ({count})")).collect())
}
/// Copy page drawing IDs, including mask art outside the ordinary child tree.
fn reid_page_art(doc: &mut Document, source: &Node) -> Node {
let mut copy = doc.reid(source);
if let Some(mask) = &mut copy.extras_mut().mask {
mask.art = Arc::new(reid_page_art(doc, &mask.art));
}
if let Some(children) = copy.children_mut() {
for child in children {
let node = reid_page_art_masks(doc, child);
*child = Arc::new(node);
}
}
copy
}
fn reid_page_art_masks(doc: &mut Document, source: &Node) -> Node {
let mut copy = source.clone();
if let Some(mask) = &mut copy.extras_mut().mask {
mask.art = Arc::new(reid_page_art(doc, &mask.art));
}
if let Some(children) = copy.children_mut() {
for child in children {
*child = Arc::new(reid_page_art_masks(doc, child));
}
}
copy
}
/// What goes into each of `infos`' slots, and whether the type is all made from the file's text
/// document. When the text document makes the type of every text object that shows (and the type
/// isn't asked for as outlines: `own` has the page's text objects that show outside placed files),
@@ -315,18 +756,20 @@ fn slot_content(
/// `doc` with the placed files `placed` lists at their other placement (see
/// [`ai::Structure::placed_elsewhere`]): each image's transform followed by its move. `None` when
/// none is listed.
/// no listed image survives replacement.
fn elsewhere(doc: &Document, placed: &[(NodeId, Affine)]) -> Option<Document> {
if placed.is_empty() {
return None;
}
let mut doc = doc.clone();
let mut moved = false;
for (id, shift) in placed {
if let Some(NodeKind::Image(im)) = doc.node_mut(*id).map(|n| &mut n.kind) {
im.xf *= *shift;
moved = true;
}
}
Some(doc)
moved.then_some(doc)
}
/// The page's type that needs no text object of the layers, taken out of `shown` and `hidden` (the
@@ -599,3 +1042,304 @@ pub(super) fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Doc
}
removed
}
#[cfg(test)]
mod candidate_tests {
use super::*;
use vectorcraft_doc::{ImageBlob, ImageObject};
/// A page image with a different proxy keeps its editing layer and original blob.
#[test]
fn page_image_with_different_proxy_keeps_editing_layer() {
let mut doc = Document::new(100.0, 100.0);
let placeholder_id = doc.alloc_id();
let placeholder = Node::path(placeholder_id, vectorcraft_geom::shapes::rectangle(Rect::new(50.0, 50.0, 54.0, 54.0)), Default::default());
doc.insert(Some(doc.layers[0].id), 0, placeholder).unwrap();
let mut page = Document::new(100.0, 100.0);
let mut editing_blob = ImageBlob::new("image/png", vec![1, 2, 3]);
editing_blob.proxy = Some(Arc::new(vec![4, 5, 6]));
let page_blob = ImageBlob::new("image/png", vec![1, 2, 3]);
let key = editing_blob.content_key();
assert_eq!(key, page_blob.content_key());
doc.images.insert(key.clone(), editing_blob.clone());
page.images.insert(key.clone(), page_blob);
let image = Node::new(
page.alloc_id(),
NodeKind::Image(Box::new(ImageObject {
key: key.clone(),
width: 4,
height: 4,
xf: Affine::translate(Vec2::new(50.0, 50.0)),
link: None,
placement: Default::default(),
pixels: None,
})),
);
page.insert(Some(page.layers[0].id), 0, image).unwrap();
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] };
let kinds = replace_unread(
&mut doc,
&frame,
&[(placeholder_id, vec!["placed files".into()])],
&[(placeholder_id, Rect::new(50.0, 50.0, 54.0, 54.0))],
&BTreeSet::new(),
&mut BTreeSet::new(),
)
.unwrap();
assert_eq!(kinds, ["placed files (1)"]);
let replacement = doc.node(placeholder_id).unwrap();
let [image] = replacement.children().unwrap().as_slice() else { panic!("{replacement:?}") };
assert!(matches!(image.kind, NodeKind::Image(_)));
assert_eq!(doc.images.get(&key), Some(&editing_blob));
}
/// An unread object across two artboards isn't replaced by the art of one page only.
#[test]
fn unread_object_across_two_pages_is_not_replaced() {
let mut doc = Document::new(200.0, 100.0);
let id = doc.alloc_id();
doc.insert(
Some(doc.layers[0].id),
0,
Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(90.0, 10.0, 110.0, 20.0)), Default::default()),
)
.unwrap();
let mut page = Document::new(200.0, 100.0);
for bounds in [Rect::new(91.0, 11.0, 99.0, 19.0), Rect::new(101.0, 11.0, 109.0, 19.0)] {
let art = Node::path(
page.alloc_id(),
vectorcraft_geom::shapes::rectangle(bounds),
vectorcraft_doc::Appearance::basic(
vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK),
vectorcraft_color::Paint::None,
0.0,
),
);
page.insert(Some(page.layers[0].id), page.layers[0].children().unwrap().len(), art).unwrap();
}
let (first, second) = (Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(100.0, 0.0, 200.0, 100.0));
let frame =
Frame { page: &page, page_rect: first, rect: first, whole: false, pages: vec![(first, Affine::IDENTITY), (second, Affine::IDENTITY)] };
let result = replace_unread(&mut doc, &frame, &[(id, vec!["placed files".into()])], &[], &BTreeSet::new(), &mut BTreeSet::new());
assert_eq!(result.unwrap_err(), "an unread object lies on more than one artboard");
}
/// An unread object reaching an artboard that has no page isn't replaced by the page's part of it.
#[test]
fn unread_object_on_an_artboard_without_a_page_is_not_replaced() {
let mut doc = Document::new(100.0, 100.0);
doc.artboards.push(vectorcraft_doc::Artboard { id: 2, rect: Rect::new(100.0, 0.0, 200.0, 100.0), ..doc.artboards[0].clone() });
let id = doc.alloc_id();
doc.insert(
Some(doc.layers[0].id),
0,
Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(90.0, 10.0, 110.0, 20.0)), Default::default()),
)
.unwrap();
let mut page = Document::new(100.0, 100.0);
let art = Node::path(
page.alloc_id(),
vectorcraft_geom::shapes::rectangle(Rect::new(91.0, 11.0, 99.0, 19.0)),
vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), vectorcraft_color::Paint::None, 0.0),
);
page.insert(Some(page.layers[0].id), 0, art).unwrap();
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] };
let result = replace_unread(&mut doc, &frame, &[(id, vec!["placed files".into()])], &[], &BTreeSet::new(), &mut BTreeSet::new());
assert_eq!(result.unwrap_err(), "an unread object lies on an artboard that has no page");
// Half a point onto it is still on it.
let narrow = Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(90.0, 10.0, 100.5, 20.0)), Default::default());
*doc.node_mut(id).unwrap() = narrow;
let result = replace_unread(&mut doc, &frame, &[(id, vec!["placed files".into()])], &[], &BTreeSet::new(), &mut BTreeSet::new());
assert_eq!(result.unwrap_err(), "an unread object lies on an artboard that has no page");
}
/// A nested placeholder's corner cannot replace its readable sibling.
#[test]
fn nested_placeholder_corner_cannot_replace_readable_sibling() {
let mut doc = Document::new(100.0, 100.0);
let group_id = doc.alloc_id();
let placeholder = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), Default::default());
let placeholder_id = placeholder.id;
let sibling = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(70.0, 70.0, 74.0, 74.0)), Default::default());
let sibling_id = sibling.id;
let group = Node::group(group_id, vec![Arc::new(placeholder), Arc::new(sibling)]);
doc.insert(Some(doc.layers[0].id), 0, group).unwrap();
let mut page = Document::new(100.0, 100.0);
let art = Node::path(
page.alloc_id(),
vectorcraft_geom::shapes::rectangle(Rect::new(81.0, 81.0, 89.0, 89.0)),
vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), vectorcraft_color::Paint::None, 0.0),
);
page.insert(Some(page.layers[0].id), 0, art).unwrap();
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] };
let result = replace_unread(
&mut doc,
&frame,
&[(group_id, vec!["placed files".into()])],
&[(placeholder_id, Rect::new(80.0, 80.0, 90.0, 90.0))],
&BTreeSet::new(),
&mut BTreeSet::new(),
);
assert_eq!(result.unwrap_err(), "an unread object's page art wasn't found in its bounds");
let group = doc.node(group_id).unwrap();
assert!(group.children().unwrap().iter().any(|child| child.id == sibling_id));
}
/// An alternative box may carry opacity missing from the first box.
#[test]
fn alternative_box_can_carry_opacity_when_first_box_cannot() {
let mut doc = Document::new(100.0, 100.0);
let id = doc.alloc_id();
let mut editing = Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), Default::default());
editing.opacity = 0.5;
doc.insert(Some(doc.layers[0].id), 0, editing).unwrap();
let mut page = Document::new(100.0, 100.0);
for (bounds, opacity) in [(Rect::new(11.0, 11.0, 19.0, 19.0), 1.0), (Rect::new(51.0, 51.0, 59.0, 59.0), 0.5)] {
let mut art = Node::path(
page.alloc_id(),
vectorcraft_geom::shapes::rectangle(bounds),
vectorcraft_doc::Appearance::basic(
vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK),
vectorcraft_color::Paint::None,
0.0,
),
);
art.opacity = opacity;
page.insert(Some(page.layers[0].id), page.layers[0].children().unwrap().len(), art).unwrap();
}
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] };
let kinds = replace_unread(
&mut doc,
&frame,
&[(id, vec!["placed files".into()])],
&[(id, Rect::new(50.0, 50.0, 60.0, 60.0))],
&BTreeSet::new(),
&mut BTreeSet::new(),
)
.unwrap();
assert_eq!(kinds, ["placed files (1)"]);
let replacement = doc.node(id).unwrap();
let [art] = replacement.children().unwrap().as_slice() else { panic!("{replacement:?}") };
assert_eq!(art.opacity, 0.5);
assert_eq!(art.geometric_bounds(), Some(Rect::new(51.0, 51.0, 59.0, 59.0)));
}
/// Opacity on one of two page objects in the box is not the unread object's opacity.
#[test]
fn opacity_on_one_of_two_page_objects_is_not_identifiable() {
let mut doc = Document::new(100.0, 100.0);
let id = doc.alloc_id();
let mut editing = Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), Default::default());
editing.opacity = 0.5;
doc.insert(Some(doc.layers[0].id), 0, editing).unwrap();
let mut page = Document::new(100.0, 100.0);
for (bounds, opacity) in [(Rect::new(11.0, 11.0, 19.0, 19.0), 0.5), (Rect::new(21.0, 21.0, 39.0, 39.0), 1.0)] {
let mut art = Node::path(
page.alloc_id(),
vectorcraft_geom::shapes::rectangle(bounds),
vectorcraft_doc::Appearance::basic(
vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK),
vectorcraft_color::Paint::None,
0.0,
),
);
art.opacity = opacity;
page.insert(Some(page.layers[0].id), page.layers[0].children().unwrap().len(), art).unwrap();
}
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] };
let result = replace_unread(&mut doc, &frame, &[(id, vec!["placed files".into()])], &[], &BTreeSet::new(), &mut BTreeSet::new());
assert_eq!(result.unwrap_err(), "an unread object's appearance is not identifiable in the page art");
}
/// A claimed first box lets a second unread object use its own corner box.
#[test]
fn second_unread_object_tries_corner_after_first_box_is_claimed() {
let mut doc = Document::new(100.0, 100.0);
let first = doc.alloc_id();
let second = doc.alloc_id();
for (id, bounds) in [(first, Rect::new(10.0, 10.0, 20.0, 20.0)), (second, Rect::new(12.0, 12.0, 22.0, 22.0))] {
let node = Node::path(id, vectorcraft_geom::shapes::rectangle(bounds), Default::default());
doc.insert(Some(doc.layers[0].id), doc.layers[0].children().unwrap().len(), node).unwrap();
}
let mut page = Document::new(100.0, 100.0);
for bounds in [Rect::new(14.0, 14.0, 16.0, 16.0), Rect::new(51.0, 51.0, 59.0, 59.0)] {
let art = Node::path(
page.alloc_id(),
vectorcraft_geom::shapes::rectangle(bounds),
vectorcraft_doc::Appearance::basic(
vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK),
vectorcraft_color::Paint::None,
0.0,
),
);
page.insert(Some(page.layers[0].id), page.layers[0].children().unwrap().len(), art).unwrap();
}
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] };
let unread = [(first, vec!["placed files".into()]), (second, vec!["placed files".into()])];
let mut one_box_doc = doc.clone();
let kinds = replace_unread(&mut doc, &frame, &unread, &[(second, Rect::new(50.0, 50.0, 60.0, 60.0))], &BTreeSet::new(), &mut BTreeSet::new())
.unwrap();
assert_eq!(kinds, ["placed files (2)"]);
let first_art = doc.node(first).unwrap().children().unwrap()[0].geometric_bounds();
let second_art = doc.node(second).unwrap().children().unwrap()[0].geometric_bounds();
assert_eq!(first_art, Some(Rect::new(14.0, 14.0, 16.0, 16.0)));
assert_eq!(second_art, Some(Rect::new(51.0, 51.0, 59.0, 59.0)));
assert_eq!(
replace_unread(&mut one_box_doc, &frame, &unread, &[], &BTreeSet::new(), &mut BTreeSet::new()).unwrap_err(),
"one page object matches more than one unread object"
);
}
/// Only the page's type in the page art that replaces an unread object is taken out of the
/// page's type for the text objects: type in the object's other box, which isn't used, stays.
#[test]
fn only_page_type_in_the_chosen_box_is_taken_for_an_unread_object() {
let mut doc = Document::new(100.0, 100.0);
let id = doc.alloc_id();
doc.insert(
Some(doc.layers[0].id),
0,
Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), Default::default()),
)
.unwrap();
let mut page = Document::new(100.0, 100.0);
let mut nodes = vec![];
for bounds in [Rect::new(12.0, 12.0, 18.0, 18.0), Rect::new(52.0, 52.0, 58.0, 58.0)] {
let art = Node::path(
page.alloc_id(),
vectorcraft_geom::shapes::rectangle(bounds),
vectorcraft_doc::Appearance::basic(
vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK),
vectorcraft_color::Paint::None,
0.0,
),
);
nodes.push(Arc::new(art.clone()));
page.insert(Some(page.layers[0].id), page.layers[0].children().unwrap().len(), art).unwrap();
}
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] };
let unread = [(id, vec!["placed files".into()])];
let mut shown: Vec<Vec<Arc<Node>>> = nodes.iter().map(|n| vec![Arc::clone(n)]).collect();
let taken = take_unread_type(&doc, Some(&frame), &unread, &[(id, Rect::new(50.0, 50.0, 60.0, 60.0))], &mut shown);
assert_eq!(taken.iter().flatten().map(|n| n.id).collect::<Vec<_>>(), [nodes[0].id]);
assert_eq!(shown.iter().flatten().map(|n| n.id).collect::<Vec<_>>(), [nodes[1].id]);
let mut copied = BTreeSet::new();
replace_unread(&mut doc, &frame, &unread, &[], &BTreeSet::new(), &mut copied).unwrap();
assert!(copied.contains(&nodes[0].id) && !copied.contains(&nodes[1].id));
}
}
+1
View File
@@ -24,6 +24,7 @@ pub(super) const LAYERS_UNREAD: &str = "the file's layers weren't read";
pub fn is_loss(note: &str) -> bool {
note.ends_with(TEXT_LEFT_OUT)
|| note.ends_with(TYPE_LEFT_OUT)
|| note.ends_with("they came in as the page's art")
|| note == PAGE_TYPE_LEFT_OUT
|| note == PAGE_TYPE_UNCHECKED
|| [ART_LEFT_OUT, LAYERS_UNREAD, crate::import::graphics::TOO_MUCH, crate::import::graphics::FAR_AWAY].iter().any(|n| note.starts_with(n))
+720
View File
@@ -53,6 +53,10 @@ fn square(x: u32, y: u32) -> String {
format!("0 0 1 0 k\n{x} {y} m\n{} {y} L\n{} {} L\n{x} {} L\nf", x + 4, x + 4, y + 4, y + 4)
}
fn square_path(x: u32, y: u32) -> String {
format!("{x} {y} m\n{} {y} L\n{} {} L\n{x} {} L\nf", x + 4, x + 4, y + 4, y + 4)
}
/// An EPS whose page draws `page` and whose private data is `native`.
fn eps(page: &str, native: &str) -> Vec<u8> {
let stream = ascii85(&zstd(native));
@@ -258,6 +262,667 @@ fn pdf_part(x: f64, y: f64) -> Document {
d
}
fn pdf_two_squares() -> Document {
let mut page = pdf_part(10.0, 86.0);
let other = pdf_part(50.0, 86.0);
let layer = page.layers[0].id;
let mut square = page.reid(&other.layers[0].children().unwrap()[0]);
square.appearance = vectorcraft_doc::Appearance {
items: vec![vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(
vectorcraft_color::Color::cmyk(1.0, 0.0, 0.0, 0.0),
)))],
..Default::default()
}
.into();
page.insert(Some(layer), 1, square).unwrap();
page
}
fn pdf_three_squares() -> Document {
let mut page = pdf_two_squares();
let other = pdf_part(70.0, 86.0);
let layer = page.layers[0].id;
let square = page.reid(&other.layers[0].children().unwrap()[0]);
page.insert(Some(layer), 2, square).unwrap();
page
}
fn pdf_split_square() -> Document {
let mut page = pdf_part(10.0, 86.0);
let layer = page.layers[0].id;
for (i, color) in [vectorcraft_color::Color::cmyk(1.0, 0.0, 0.0, 0.0), vectorcraft_color::Color::cmyk(0.0, 1.0, 0.0, 0.0)].into_iter().enumerate()
{
let x = 50.0 + 2.0 * i as f64;
let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 86.0, x + 2.0, 90.0));
let appearance = vectorcraft_doc::Appearance {
items: vec![vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(color)))],
..Default::default()
};
let node = Node::path(page.alloc_id(), path, appearance);
page.insert(Some(layer), i + 1, node).unwrap();
}
page
}
fn os1_group(body: &str) -> String {
format!("u\n{body}\nU\n")
}
fn replacement_is_cyan(doc: &Document, layer: usize, child: usize) {
let group = &doc.layers[layer].children().unwrap()[child];
let art = &group.children().unwrap()[0];
assert!(matches!(art.kind, NodeKind::Path { .. }));
assert!(matches!(art.appearance.items.first(), Some(vectorcraft_doc::AppearanceItem::Fill(fill))
if fill.paint == vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(1.0, 0.0, 0.0, 0.0))));
}
fn replacement_has_two_halves(doc: &Document) {
let group = &doc.layers[1].children().unwrap()[0];
let art = group.children().unwrap();
assert_eq!(art.len(), 2);
assert_eq!(art[0].geometric_bounds().unwrap().x0, 50.0);
assert_eq!(art[1].geometric_bounds().unwrap().x0, 52.0);
assert!(matches!(art[0].appearance.items.first(), Some(vectorcraft_doc::AppearanceItem::Fill(fill))
if fill.paint == vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(1.0, 0.0, 0.0, 0.0))));
assert!(matches!(art[1].appearance.items.first(), Some(vectorcraft_doc::AppearanceItem::Fill(fill))
if fill.paint == vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 1.0, 0.0, 0.0))));
}
#[test]
fn top_level_symbol_uses_page_art_beside_live_path() {
let symbol = format!("%AI14_BeginSymbol\n(Dot) 0 Ae\n{}\n%AI10_EndSymbol\n", square(0, 0));
let instance = "/SymbolInstance :\n(Dot) /SymbolRef ,\n[1 0 0 1 50 10] /Matrix ,\n;";
let art = native(&(symbol + &layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &format!("{instance}\n{}", square(70, 10)))));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_three_squares(), vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Mixed"]);
assert_eq!(kinds(&doc.layers[1]), ["group[1]", "path"]);
replacement_is_cyan(&doc, 1, 0);
assert!(notes.iter().any(|n| n.contains("symbols (1)")), "{notes:?}");
}
#[test]
fn group_with_only_symbol_instance_uses_page_art() {
let symbol = format!("%AI14_BeginSymbol\n(Dot) 0 Ae\n{}\n%AI10_EndSymbol\n", square(0, 0));
let instance = "/SymbolInstance :\n(Dot) /SymbolRef ,\n[1 0 0 1 50 10] /Matrix ,\n;";
let art = native(&(symbol + &layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &os1_group(instance))));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_two_squares(), vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Mixed"]);
assert_eq!(kinds(&doc.layers[1]), ["group[1]"]);
replacement_is_cyan(&doc, 1, 0);
assert!(notes.iter().any(|n| n.contains("symbols (1)")), "{notes:?}");
}
#[test]
fn unread_group_uses_page_opacity_once() {
let symbol = format!("%AI14_BeginSymbol\n(Dot) 0 Ae\n{}\n%AI10_EndSymbol\n", square(0, 0));
let instance = "/SymbolInstance :\n(Dot) /SymbolRef ,\n[1 0 0 1 50 10] /Matrix ,\n;";
let group = format!("0 Ae\nu\n{instance}\nU\n0 0.5 0 0 0 Xy\n0 0 Xd\n6 () XW");
let art = native(&(symbol + &layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &group)));
let mut page = pdf_two_squares();
let id = page.layers[0].children().unwrap()[1].id;
page.node_mut(id).unwrap().opacity = 0.5;
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Mixed"]);
let replacement = &doc.layers[1].children().unwrap()[0];
assert_eq!(replacement.opacity, 1.0);
assert_eq!(replacement.children().unwrap()[0].opacity, 0.5);
let rendered = vectorcraft_render::Renderer::new().render_region(&doc, vectorcraft_geom::Rect::new(50.0, 86.0, 54.0, 90.0), 4.0, false);
let pixel = rendered.pixels.as_chunks::<4>().0.get(8 * rendered.width as usize + 8).unwrap();
assert!((120..=136).contains(&pixel[3]), "the final document draws the group at 50% once: {pixel:?}");
}
#[test]
fn unread_group_with_unidentified_transparency_uses_page_document() {
let symbol = format!("%AI14_BeginSymbol\n(Dot) 0 Ae\n{}\n%AI10_EndSymbol\n", square(0, 0));
let instance = "/SymbolInstance :\n(Dot) /SymbolRef ,\n[1 0 0 1 50 10] /Matrix ,\n;";
let group = format!("0 Ae\nu\n{instance}\nU\n0 0.5 0 0 0 Xy\n0 0 Xd\n6 () XW");
let art = native(&(symbol + &layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &group)));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_two_squares(), vec![], false, false, 1.0);
assert!(!read, "{notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
assert!(notes.iter().any(|n| n.contains("appearance is not identifiable")), "{notes:?}");
}
#[test]
fn group_with_only_placed_file_without_preview_uses_page_art() {
let placed = "%AI5_BeginPlace\n[1 0 0 1 0 0] 50 10 54 14 (missing) `\n~\n%AI5_EndPlace";
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &os1_group(placed))));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_two_squares(), vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Mixed"]);
assert_eq!(kinds(&doc.layers[1]), ["group[1]"]);
replacement_is_cyan(&doc, 1, 0);
assert!(notes.iter().any(|n| n.contains("placed files (1)")), "{notes:?}");
}
#[test]
fn top_level_pattern_uses_page_art_beside_live_path() {
let body = format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}\n{}", square_path(50, 10), square(70, 10));
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &body)));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_three_squares(), vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Mixed"]);
assert_eq!(kinds(&doc.layers[1]), ["group[1]", "path"]);
replacement_is_cyan(&doc, 1, 0);
assert!(notes.iter().any(|n| n.contains("pattern fills (1)")), "{notes:?}");
}
#[test]
fn pattern_set_in_plain_group_replaces_later_sibling_from_page_art() {
let group = os1_group(&format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}", square_path(50, 10)));
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &format!("{group}\n{}", square_path(70, 10)))));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_three_squares(), vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Mixed"]);
assert_eq!(kinds(&doc.layers[1]), ["group[1]", "group[1]"]);
replacement_is_cyan(&doc, 1, 0);
assert!(notes.iter().any(|n| n.contains("pattern fills (2)")), "{notes:?}");
}
#[test]
fn top_level_placed_file_without_preview_uses_page_art() {
let placed = "%AI5_BeginPlace\n[1 0 0 1 0 0] 50 10 54 14 (missing) `\n~\n%AI5_EndPlace";
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &format!("{placed}\n{}", square(70, 10)))));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_three_squares(), vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Mixed"]);
assert_eq!(kinds(&doc.layers[1]), ["group[1]", "path"]);
replacement_is_cyan(&doc, 1, 0);
assert!(notes.iter().any(|n| n.contains("placed files (1)")), "{notes:?}");
}
#[test]
fn nested_previewless_placed_file_at_box_corner_falls_back() {
let place = "%AI5_BeginPlace\n[1 0 0 -1 -7970 8070] 8000 8000 8040 8040 4 4 0\n(missing.png)`\n~\n%AI5_EndPlace";
let art = native(&layer("Placed", true, &os1_group(place))).replace("%AI5_NumLayers", "%AI3_TemplateBox: 100 50 100 50\n%AI5_NumLayers");
let mut page = Document::new(100.0, 100.0);
let id = page.alloc_id();
let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(30.0, 30.0, 70.0, 70.0));
let fill = vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::gray(0.5)));
page.insert(
Some(page.layers[0].id),
0,
Node::path(id, path, vectorcraft_doc::Appearance { items: vec![vectorcraft_doc::AppearanceItem::Fill(fill)], ..Default::default() }),
)
.unwrap();
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(!read, "a nested placeholder has no group-wide corner alternative: {notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
}
#[test]
fn group_with_previewless_placed_file_and_readable_sibling_keeps_sibling() {
let place = "%AI5_BeginPlace\n[1 0 0 -1 -7970 8070] 8000 8000 8040 8040 4 4 0\n(missing.png)`\n~\n%AI5_EndPlace";
let group = os1_group(&format!("{place}\n{}", square(70, 10)));
let art = native(&layer("Mixed", true, &group)).replace("%AI5_NumLayers", "%AI3_TemplateBox: 100 50 100 50\n%AI5_NumLayers");
let mut page = pdf_part(70.0, 86.0);
let id = page.alloc_id();
let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(30.0, 30.0, 70.0, 70.0));
let fill = vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::gray(0.5)));
page.insert(
Some(page.layers[0].id),
1,
Node::path(id, path, vectorcraft_doc::Appearance { items: vec![vectorcraft_doc::AppearanceItem::Fill(fill)], ..Default::default() }),
)
.unwrap();
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
if read {
let mixed = doc.layers.iter().find(|layer| layer.name.as_deref() == Some("Mixed")).unwrap();
assert!(
mixed.children().unwrap().iter().any(|child| {
child.children().is_some_and(|children| children.iter().any(|n| n.geometric_bounds().is_some_and(|b| b.x0 == 70.0)))
}),
"readable sibling was dropped: {notes:?}"
);
} else {
assert_eq!(names(&doc), ["Layer 1"], "{notes:?}");
}
}
#[test]
fn previewless_placed_file_tries_corner_when_first_box_contains_live_art() {
let place = "%AI5_BeginPlace\n[1 0 0 -1 -7970 8070] 8000 8000 8040 8040 4 4 0\n(missing.png)`\n~\n%AI5_EndPlace";
let live = "0 0 1 0 k\n130 -180 m\n134 -180 L\n134 -184 L\n130 -184 L\nf";
let art = native(&(layer("Live", true, live) + &layer("Placed", true, place)))
.replace("%AI5_NumLayers", "%AI3_TemplateBox: 100 50 100 50\n%AI5_NumLayers");
let mut page = Document::new(100.0, 100.0);
let id = page.alloc_id();
let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(30.0, 30.0, 70.0, 70.0));
let fill = vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::gray(0.5)));
page.insert(
Some(page.layers[0].id),
0,
Node::path(id, path, vectorcraft_doc::Appearance { items: vec![vectorcraft_doc::AppearanceItem::Fill(fill)], ..Default::default() }),
)
.unwrap();
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Placed"]);
assert_eq!(kinds(&doc.layers[0]), ["path"]);
assert_eq!(kinds(&doc.layers[1]), ["group[1]"]);
assert!(notes.iter().any(|note| note.contains("placed files (1)")), "{notes:?}");
}
#[test]
fn replaced_page_art_reids_opacity_mask_art() {
let place = "%AI5_BeginPlace\n[1 0 0 1 0 0] 50 10 54 14 (missing) `\n~\n%AI5_EndPlace";
let art = native(&layer("Placed", true, place));
let mut page = pdf_part(50.0, 86.0);
let page_node_id = page.layers[0].children().unwrap()[0].id;
let mask_id = page.alloc_id();
let mask_path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(50.0, 86.0, 54.0, 90.0));
let mask_fill = vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::WHITE));
let mask_art = Node::path(
mask_id,
mask_path,
vectorcraft_doc::Appearance { items: vec![vectorcraft_doc::AppearanceItem::Fill(mask_fill)], ..Default::default() },
);
page.node_mut(page_node_id).unwrap().extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(mask_art, true)));
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(read, "{notes:?}");
let replacement = &doc.layers[0].children().unwrap()[0].children().unwrap()[0];
let mask = replacement.extras().mask.as_ref().unwrap();
assert_ne!(mask.art.id, mask_id);
assert_ne!(mask.art.id, replacement.id);
assert!(mask.art.id.0 < doc.peek_next_id());
}
#[test]
fn top_level_placed_file_replacement_keeps_page_image_data() {
use vectorcraft_doc::{ImageBlob, ImageObject};
let placed = "%AI5_BeginPlace\n[1 0 0 1 0 0] 50 10 54 14 (missing) `\n~\n%AI5_EndPlace";
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, placed)));
let mut page = pdf_part(10.0, 86.0);
let pixels = image::RgbaImage::from_pixel(4, 4, image::Rgba([0, 255, 255, 255]));
let mut png = Vec::new();
pixels.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();
let expected = blob.bytes.clone();
page.images.insert(key.clone(), blob);
let image = Node::new(
page.alloc_id(),
NodeKind::Image(Box::new(ImageObject {
key: key.clone(),
width: 4,
height: 4,
xf: vectorcraft_geom::Affine::translate(vectorcraft_geom::Vec2::new(50.0, 86.0)),
link: None,
placement: Default::default(),
pixels: None,
})),
);
page.insert(Some(page.layers[0].id), 1, image).unwrap();
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Mixed"]);
let replacement = &doc.layers[1].children().unwrap()[0];
let [image] = replacement.children().unwrap().as_slice() else { panic!("{replacement:?}") };
let NodeKind::Image(im) = &image.kind else { panic!("{image:?}") };
assert!(im.pixels.is_none(), "PDF-imported images use document data");
assert_eq!(doc.images.get(&im.key).map(|blob| &blob.bytes), Some(&expected));
assert!(notes.iter().any(|note| note.contains("placed files (1)")), "{notes:?}");
}
#[test]
fn top_level_pattern_replacement_keeps_gradient_stop_swatch() {
use vectorcraft_color::gradient::{GradientKind, GradientPaint, GradientStop};
use vectorcraft_color::{Color, Gradient, Paint, Swatch};
let body = format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}", square_path(50, 10));
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &body)));
let mut page = pdf_two_squares();
let cyan = Color::cmyk(1.0, 0.0, 0.0, 0.0);
page.swatches.push(Swatch { name: "Linked Cyan".into(), paint: Paint::solid(cyan), global: true, spot: false });
let mut stop = GradientStop::new(0.0, cyan);
stop.swatch = Some("Linked Cyan".into());
let gradient = Gradient::new(GradientKind::Linear, vec![stop, GradientStop::new(1.0, cyan)]);
let id = page.layers[0].children().unwrap()[1].id;
let node = page.node_mut(id).unwrap();
node.appearance = vectorcraft_doc::Appearance {
items: vec![vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(Paint::Gradient(Box::new(GradientPaint::new(gradient)))))],
..Default::default()
}
.into();
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(read, "{notes:?}");
let group = &doc.layers[1].children().unwrap()[0];
let replacement = &group.children().unwrap()[0];
assert!(matches!(replacement.appearance.fill().map(|f| &f.paint), Some(Paint::Gradient(g))
if g.gradient.stops[0].swatch.as_deref() == Some("Linked Cyan")));
assert!(matches!(doc.swatch("Linked Cyan"), Some(s) if s.global && s.paint == Paint::solid(cyan)));
}
/// Two independently imported copies of the same tile have different node IDs.
fn page_with_named_pattern(native_art: &str, different: bool) -> Document {
use vectorcraft_color::Paint;
let (editing, _) = crate::ai_alone(&private(&native_art.replace("(Absent)", "(Dots)"))).unwrap();
let source = editing.pattern("Dots").unwrap();
let mut page = pdf_two_squares();
let mut pattern = source.clone();
pattern.art = source.art.iter().map(|node| std::sync::Arc::new(page.reid(node))).collect();
if different {
pattern.tile.x1 += 1.0;
}
page.patterns.push(pattern);
let id = page.layers[0].children().unwrap()[1].id;
let node = page.node_mut(id).unwrap();
node.appearance = vectorcraft_doc::Appearance {
items: vec![vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(Paint::Pattern {
pattern: "Dots".into(),
xf: vectorcraft_geom::Affine::IDENTITY,
}))],
..Default::default()
}
.into();
page
}
fn named_pattern_replacement_art() -> String {
let dots = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 4 4\n%_0 0 0 1 k\n%_1 1 m\n%_3 1 L\n%_3 3 L\n%_1 3 L\n%_f\nE\n%AI3_EndPattern\n";
let unread = format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}", square_path(50, 10));
native(&format!("{dots}{}{}", layer("Live", true, &square(10, 10)), layer("Mixed", true, &unread)))
}
#[test]
fn same_named_pattern_in_page_art_keeps_editing_layers() {
let art = named_pattern_replacement_art();
let page = page_with_named_pattern(&art, false);
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Mixed"]);
assert_eq!(doc.patterns.iter().filter(|p| p.name == "Dots").count(), 1);
let replacement = &doc.layers[1].children().unwrap()[0].children().unwrap()[0];
assert!(matches!(replacement.appearance.fill().map(|f| &f.paint), Some(vectorcraft_color::Paint::Pattern { pattern, .. }) if pattern == "Dots"));
}
#[test]
fn copied_page_pattern_reids_its_mask_art() {
let source_art = named_pattern_replacement_art();
let mut page = page_with_named_pattern(&source_art, false);
let mask_id = page.alloc_id();
let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 4.0, 4.0));
let fill = vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::WHITE));
let mask_art =
Node::path(mask_id, path, vectorcraft_doc::Appearance { items: vec![vectorcraft_doc::AppearanceItem::Fill(fill)], ..Default::default() });
std::sync::Arc::make_mut(&mut page.patterns[0].art[0]).extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(mask_art, true)));
let art = native(&format!(
"{}{}",
layer("Live", true, &square(10, 10)),
layer("Mixed", true, &format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}", square_path(50, 10)))
));
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(read, "{notes:?}");
let mask = doc.pattern("Dots").unwrap().art[0].extras().mask.as_ref().unwrap();
assert_ne!(mask.art.id, mask_id);
assert!(mask.art.id.0 < doc.peek_next_id());
}
#[test]
fn different_named_pattern_in_page_art_falls_back() {
let art = named_pattern_replacement_art();
let page = page_with_named_pattern(&art, true);
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(!read, "{notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
assert!(notes.iter().any(|n| n.contains("page art conflicts with pattern Dots")), "{notes:?}");
}
#[test]
fn partly_overlapping_page_art_does_not_replace_an_object() {
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &os1_group(&format!("{}\nZZ", square(50, 10))))));
let mut page = pdf_part(10.0, 86.0);
let other = pdf_part(48.0, 86.0);
let id = page.layers[0].id;
let square = page.reid(&other.layers[0].children().unwrap()[0]);
page.insert(Some(id), 1, square).unwrap();
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(!read, "{notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
assert!(notes.iter().any(|n| n.contains("page art wasn't found")), "{notes:?}");
}
/// Page art for the unread square and its neighbor under one PDF group.
fn grouped_page_across_unread_and_neighbor(clipped: bool) -> Document {
let mut page = pdf_two_squares();
let layer = page.layers[0].id;
let unread = page.remove(page.layers[0].children().unwrap()[1].id).unwrap();
let other = pdf_part(70.0, 86.0);
let neighbor = page.reid(&other.layers[0].children().unwrap()[0]);
let mut children = Vec::new();
if clipped {
let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(50.0, 86.0, 74.0, 90.0));
let mut clip = Node::path(page.alloc_id(), path, vectorcraft_doc::Appearance::default());
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
children.push(std::sync::Arc::new(clip));
}
children.extend([unread, std::sync::Arc::new(neighbor)]);
let mut group = Node::group(page.alloc_id(), children);
group.set_clips(clipped);
page.insert(Some(layer), 1, group).unwrap();
page
}
#[test]
fn clipped_pdf_group_across_unread_and_neighbor_falls_back() {
let body = format!("{}\n{}", os1_group(&format!("{}\nZZ", square(50, 10))), square(70, 10));
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &body)));
let (doc, notes, read) = crate::layered_ai(&private(&art), grouped_page_across_unread_and_neighbor(true), vec![], false, false, 1.0);
assert!(!read, "{notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
assert!(notes.iter().any(|n| n.contains("clipping or appearance ancestor")), "{notes:?}");
}
#[test]
fn plain_pdf_group_across_unread_and_neighbor_uses_child() {
let body = format!("{}\n{}", os1_group(&format!("{}\nZZ", square(50, 10))), square(70, 10));
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &body)));
let (doc, notes, read) = crate::layered_ai(&private(&art), grouped_page_across_unread_and_neighbor(false), vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Mixed"]);
replacement_is_cyan(&doc, 1, 0);
assert!(notes.iter().any(|n| n.contains("`ZZ` (1)")), "{notes:?}");
}
#[test]
fn live_art_inside_unread_bounds_falls_back_instead_of_drawing_twice() {
let body = format!("{}\n{}", os1_group(&format!("{}\nZZ", square(50, 10))), "0 0 1 0 k\n51 11 m\n53 11 L\n53 13 L\n51 13 L\nf");
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &body)));
let mut page = pdf_two_squares();
let inner = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(51.0, 87.0, 53.0, 89.0));
let appearance = (*page.layers[0].children().unwrap()[0].appearance).clone();
let node = Node::path(page.alloc_id(), inner, appearance);
let layer_id = page.layers[0].id;
page.insert(Some(layer_id), 2, node).unwrap();
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(!read, "{notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
assert!(notes.iter().any(|n| n.contains("bounds also contain live art")), "{notes:?}");
}
#[test]
fn live_art_crossing_unread_bounds_by_half_a_point_falls_back() {
let neighbor = "0 0 1 0 k\n53.5 11 m\n54.5 11 L\n54.5 13 L\n53.5 13 L\nf";
let art = native(&layer("Mixed", true, &format!("{}\n{neighbor}", os1_group(&format!("{}\nZZ", square(50, 10))))));
let mut page = pdf_part(50.0, 86.0);
let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(53.5, 87.0, 54.5, 89.0));
let appearance = (*page.layers[0].children().unwrap()[0].appearance).clone();
let node = Node::path(page.alloc_id(), path, appearance);
let layer = page.layers[0].id;
page.insert(Some(layer), 1, node).unwrap();
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(!read, "{notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
assert!(notes.iter().any(|n| n.contains("bounds also contain live art")), "{notes:?}");
}
#[test]
fn overlapping_unread_objects_cannot_claim_the_same_page_art() {
let first = os1_group(&format!("{}\nZZ", square(50, 10)));
let second = os1_group(&format!("{}\nZZ", square(52, 10)));
let art = native(&layer("Mixed", true, &format!("{first}\n{second}")));
let mut page = pdf_part(52.0, 86.0);
let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(52.0, 86.0, 54.0, 90.0));
let node = page.layers[0].children().unwrap()[0].as_ref().clone();
let layer = page.layers[0].id;
page.remove(node.id).unwrap();
let replacement = Node::path(page.alloc_id(), path, (*node.appearance).clone());
page.insert(Some(layer), 0, replacement).unwrap();
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(!read, "{notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
assert!(notes.iter().any(|n| n.contains("more than one unread object")), "{notes:?}");
}
#[test]
fn top_level_pattern_without_page_art_falls_back() {
let body = format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}", square_path(50, 10));
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &body)));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0);
assert!(!read, "{notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
assert!(notes.iter().any(|n| n.contains("page art wasn't found")), "{notes:?}");
}
#[test]
fn top_level_placed_file_without_page_art_falls_back() {
let placed = "%AI5_BeginPlace\n[1 0 0 1 0 0] 50 10 54 14 (missing) `\n~\n%AI5_EndPlace";
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, placed)));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0);
assert!(!read, "{notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
assert!(notes.iter().any(|n| n.contains("page art wasn't found")), "{notes:?}");
}
#[test]
fn unread_object_on_second_artboard_takes_second_page_art() {
let boards = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\
%_/Dictionary :\n%_0 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
%_/Dictionary :\n%_0 -50 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 -150 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n";
let second = "u\n0 0 1 0 k\n50 -100 m\n54 -100 L\n54 -96 L\n50 -96 L\nf\nZZ\nU";
let art = native(&(boards.to_string() + &layer("Live", true, &square(10, 10)) + &layer("Mixed", true, second)));
let mut page = pdf_part(10.0, 86.0);
let mut board = page.artboards[0].clone();
(board.id, board.rect) = (2, vectorcraft_geom::Rect::new(120.0, 0.0, 220.0, 100.0));
page.artboards.push(board);
let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(170.0, 46.0, 174.0, 50.0));
let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(1.0, 0.0, 0.0, 0.0));
let appearance = vectorcraft_doc::Appearance {
items: vec![vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(paint))],
..Default::default()
};
let node = Node::path(page.alloc_id(), path, appearance);
let layer = page.layers[0].id;
page.insert(Some(layer), 1, node).unwrap();
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Mixed"]);
replacement_is_cyan(&doc, 1, 0);
let bounds = doc.layers[1].children().unwrap()[0].visual_bounds().unwrap();
assert_eq!(bounds, vectorcraft_geom::Rect::new(50.0, 196.0, 54.0, 200.0));
}
#[test]
fn unknown_operator_between_objects_falls_back() {
let body = format!("{}\nZZ\n{}", square(50, 10), square(70, 10));
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &body)));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_three_squares(), vec![], false, false, 1.0);
assert!(!read, "{notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
assert!(notes.iter().any(|n| n.contains("`ZZ`")), "{notes:?}");
}
#[test]
fn unread_object_without_page_art_names_what_is_unread() {
let art = native(&layer("Mixed", true, &os1_group(&format!("{}\nZZ", square(50, 10)))));
let e = crate::ai_alone(&private(&art)).unwrap_err();
assert_eq!(e, "it has operators VectorCraft doesn't know (`ZZ`), which VectorCraft doesn't read from it yet");
let pattern = os1_group(&format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}", square_path(70, 10)));
let art = native(&layer("Mixed", true, &format!("{}\n{pattern}", os1_group(&format!("{}\nZZ", square(50, 10))))));
let e = crate::ai_alone(&private(&art)).unwrap_err();
assert_eq!(e, "it has operators VectorCraft doesn't know (`ZZ`) and pattern fills, which VectorCraft doesn't read from it yet");
}
#[test]
fn unread_pattern_inside_one_object_uses_page_art_and_keeps_layers() {
let art = native(
&(layer("Live", true, &square(10, 10))
+ &layer(
"Mixed",
true,
&format!("{}\n{}", os1_group(&format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}", square_path(50, 10))), square(70, 10)),
)),
);
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_three_squares(), vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Mixed"]);
assert_eq!(kinds(&doc.layers[0]), ["path"]);
assert_eq!(kinds(&doc.layers[1]), ["group[1]", "path"]);
replacement_is_cyan(&doc, 1, 0);
assert!(notes.iter().any(|n| n.contains("pattern fills (1)") && n.contains("page's art")), "{notes:?}");
}
#[test]
fn unknown_operator_inside_group_uses_page_art() {
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &os1_group(&format!("{}\nZZ", square(50, 10))))));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_split_square(), vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Mixed"]);
assert_eq!(kinds(&doc.layers[1]), ["group[2]"]);
replacement_has_two_halves(&doc);
assert!(notes.iter().any(|n| n.contains("`ZZ` (1)")), "{notes:?}");
}
#[test]
fn unread_object_on_hidden_layer_stays_left_out() {
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Hidden", false, &os1_group(&format!("{}\nZZ", square(50, 10))))));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Hidden"]);
assert!(notes.iter().any(|n| n.contains("left out of them")), "{notes:?}");
}
#[test]
fn unread_object_without_page_art_falls_back() {
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &os1_group(&format!("{}\nZZ", square(50, 10))))));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0);
assert!(!read, "{notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
assert!(notes.iter().any(|n| n.contains("page art wasn't found")), "{notes:?}");
}
#[test]
fn unclosed_unread_group_falls_back() {
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &format!("u\n{}\nZZ", square(50, 10)))));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_two_squares(), vec![], false, false, 1.0);
assert!(!read, "{notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
assert!(notes.iter().any(|n| n.contains("closes something it didn't open")), "{notes:?}");
}
#[test]
fn eps_unread_group_uses_printed_page_art() {
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &os1_group(&format!("{}\nZZ", square(50, 10))))));
let page = format!(
"{}\n1 0 0 0 setcmykcolor 50 10 moveto 52 10 lineto 52 14 lineto 50 14 lineto closepath fill\n0 1 0 0 setcmykcolor 52 10 moveto 54 10 lineto 54 14 lineto 52 14 lineto closepath fill",
page_square(10, 10)
);
let result = import(&eps(&page, &art)).unwrap();
assert_eq!(names(&result.document), ["Live", "Mixed"], "{:?}", result.warnings);
replacement_has_two_halves(&result.document);
assert!(result.warnings.iter().any(|n| n.contains("`ZZ` (1)")), "{:?}", result.warnings);
}
/// The PDF part `d` as read in points from pages of `k` points a unit (their `UserUnit`): its
/// pages and art `k` times as large.
fn in_points(mut d: Document, k: f64) -> Document {
@@ -1992,6 +2657,61 @@ fn threaded_area_type_flows_through_its_frames() {
assert_eq!(r.document.text_threads, [ids]);
}
/// Replacing a group containing either threaded frame must use the PDF page instead.
#[test]
fn unread_group_holding_threaded_frame_falls_back() {
let frames = [
format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 >> >> >>", canvas_rect(50.0, 10.0, 54.0, 14.0)),
format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 >> >> >>", canvas_rect(70.0, 10.0, 74.0, 14.0)),
];
let text = document_of(&frames, &[story("Threaded\\r", &[0, 1], "/0 0", "/1 10.0")]);
for removed in [0, 1] {
let grouped = os1_group(&format!("{}\n{}\nZZ", square(if removed == 0 { 50 } else { 70 }, 10), frame_object(0, removed)));
let other = frame_object(0, 1 - removed);
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Mixed", true, &format!("{grouped}\n{other}"))), &text);
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_three_squares(), vec![], false, true, 1.0);
assert!(!read, "removed frame {removed}: {notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
assert!(notes.iter().any(|n| n.contains("an unread object holds a frame of threaded type")), "removed frame {removed}: {notes:?}");
}
}
/// Replacing an unread group may still discard a single-frame text object.
#[test]
fn unread_group_holding_single_frame_uses_page_art() {
let frame = format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 >> >> >>", canvas_rect(50.0, 10.0, 54.0, 14.0));
let text = document_of(&[frame], &[story("Single\\r", &[0], "/0 0", "/1 10.0")]);
let grouped = os1_group(&format!("{}\n{}\nZZ", square(50, 10), frame_object(0, 0)));
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Mixed", true, &grouped)), &text);
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_two_squares(), vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Back", "Mixed"]);
assert!(doc.text_threads.is_empty());
}
/// The page's type inside an unread object's box comes in once, with the page's art that replaces
/// the object, whether every text object's type is made from the text document or not.
#[test]
fn page_type_inside_an_unread_object_comes_in_with_its_page_art() {
let unread = os1_group("0 0 1 0 k\n20 10 m\n50 10 L\n50 30 L\n20 30 L\nf\nZZ");
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show \
1 0 0 0 setcmykcolor 20 10 moveto 50 10 lineto 50 30 lineto 20 30 lineto closepath fill \
0 0 0 1 setcmykcolor /Helvetica findfont 8 scalefont setfont 22 14 moveto (Ex) show";
let made = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
let unmade =
last_frame_unread(&text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Note\r", 0, (8200.0, 8120.0), (0.0, 0.0), 0.0)]));
for (doc, objects) in [(made, text_object(0)), (unmade, text_object(0) + &text_object(1))] {
let art = native_with_text(&(layer("Words", true, &objects) + &layer("Mixed", true, &unread)), &doc);
let r = import(&eps(page, &art)).unwrap();
assert_eq!(names(&r.document), ["Words", "Mixed"], "{:?}", r.warnings);
let mut texts = all_texts(&r.document);
texts.retain(|t| !t.is_empty());
assert_eq!(texts, ["Hi there", "Ex"], "{:?}", r.warnings);
assert!(!r.warnings.iter().any(|w| w.starts_with("some of the page's type")), "{:?}", r.warnings);
assert!(r.warnings.iter().any(|w| w.contains("`ZZ` (1)")), "{:?}", r.warnings);
}
}
#[test]
fn shown_type_the_page_draws_in_pieces_comes_whole_from_the_text_document() {
// Type on a path the page draws letter by letter: the letters lie on the path, and only the
+41 -7
View File
@@ -114,8 +114,11 @@ pub struct GestureTool {
pub edit_within: f64,
/// "Close paths when ends are within N pixels".
pub close_within: f64,
/// Smart Guides for the Pencil's ends.
/// Smart Guides for the Pencil's ends and the press of a straight Knife cut.
snap: DrawSnap,
/// Where the pointer went down, before any snap: a straight Knife cut released there cuts
/// nothing, even when its press snapped elsewhere.
pressed: Point,
/// Where the Blob Brush's or the Eraser's tip outline shows: the pointer over the canvas,
/// unless Cmd/Ctrl lends a selection tool or Alt the Smooth tool.
tip_at: Option<Point>,
@@ -194,6 +197,7 @@ impl GestureTool {
edit_within: 12.0,
close_within: 15.0,
snap: DrawSnap::default(),
pressed: Point::ZERO,
tip_at: None,
}
}
@@ -203,6 +207,31 @@ impl GestureTool {
self.id == "pencil"
}
/// Does a straight Knife cut snap to anchors and paths? Only with Smart Guides on, which Snap to
/// Pixel and Snap to Grid turn off (`PointSnap::new`). Its ends snap as the Pencil's straight
/// line's do: the press, and the release unless Shift keeps it on a 45° step.
fn knife_snaps(&self, cx: &ToolContext) -> bool {
self.id == "knife" && self.straight && cx.smart_guides && !cx.snap_to_pixel && !cx.snap_to_grid
}
/// Does the end of a straight line snap: the Pencil's Alt line's, or a straight Knife cut's
/// without Shift?
fn line_end_snaps(&self, cx: &ToolContext) -> bool {
self.straight && self.unconstrained && (self.snaps() || self.knife_snaps(cx))
}
/// Where a stroke or straight line released at `p` ends: where it snaps when its end snaps.
fn release_end(&mut self, cx: &ToolContext, p: Point) -> Point {
if (self.snaps() && !self.smoothing && !self.straight) || self.line_end_snaps(cx) { self.snap.drag(cx, p, None) } else { p }
}
/// Is a straight line with its end at `end` back at its press point (within a pixel), so that
/// there is no line? A Knife cut also is when the pointer, at `pointer`, is back where it went
/// down, though its press snapped elsewhere.
fn back_at_press(&self, cx: &ToolContext, pointer: Point, end: Point) -> bool {
self.points.first().is_some_and(|s| s.distance(end) < cx.tol(1.0)) || (self.id == "knife" && self.pressed.distance(pointer) < cx.tol(1.0))
}
fn draws_path(&self) -> bool {
matches!(self.id, "pencil" | "paintbrush")
}
@@ -532,10 +561,14 @@ impl Tool for GestureTool {
end
} else if self.snaps() && !self.smoothing && self.extend.is_none() && self.redraw.is_none() {
self.snap.press(cx, p, &[], None)
} else if self.knife_snaps(cx) {
// A straight Knife cut, its overlay and Shift's 45° steps start from the snapped point.
self.snap.press_on_paths(cx, p)
} else {
p
};
self.clear();
self.pressed = p;
self.push(start, ev.pressure);
vec![]
}
@@ -554,19 +587,20 @@ impl Tool for GestureTool {
// Where the stroke would end: shown, taken on release.
end = self.snap.drag(cx, p, None);
}
let p = if self.straight && self.unconstrained && self.snaps() {
// Alt's line ends where a stroke would: shown, taken on release.
let p = if self.line_end_snaps(cx) {
// Alt's line ends where a stroke would, and a Knife cut's on the anchors and
// paths its press snaps to: shown, taken on release.
self.snap.drag(cx, p, None)
} else {
p
};
if self.straight {
if !self.unconstrained {
if !self.line_end_snaps(cx) {
// The press's Smart Guides would stay on screen: the line's end doesn't snap.
self.snap.clear();
}
// The line runs from the press point to the pointer, wherever the drag went.
if self.points.first().is_some_and(|s| s.distance(p) < cx.tol(1.0)) {
if self.back_at_press(cx, ev.pos, p) {
// Back at the press point there is no line: the one shown goes.
self.points.truncate(1);
return if std::mem::take(&mut self.began) { vec![Action::Cancel] } else { vec![] };
@@ -618,12 +652,12 @@ impl Tool for GestureTool {
// Alt only on release cannot turn a freehand Knife drag into a straight cut.
self.knife_shift(ev.mods);
let p = self.constrained(cx, p, ev.mods);
let end = if self.snaps() && !self.smoothing && (!self.straight || self.unconstrained) { self.snap.drag(cx, p, None) } else { p };
let end = self.release_end(cx, p);
self.snap.clear();
let mut cancel = false;
if self.straight {
self.points.truncate(1);
if self.points.first().is_some_and(|s| s.distance(end) >= cx.tol(1.0)) {
if !self.back_at_press(cx, ev.pos, end) {
self.push(end, ev.pressure);
} else {
cancel = self.began;
+97
View File
@@ -1351,6 +1351,103 @@ fn knife_shift_alt_cuts_at_45_degree_steps() {
assert_eq!(knife_points(acts.last().unwrap()), vec![(0.0, 0.0), (30.0, -10.0), (50.0, -60.0)]);
}
/// With Smart Guides on, a straight Knife cut starts at the anchor or path its press snaps to,
/// and its release snaps the same way, as the Pencil's straight line's ends do. Centers and lines
/// up do not move the press. The overlay shows the same line.
#[test]
fn knife_alt_cut_snaps_where_smart_guides_snap() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let alt = Mods { alt: true, ..Default::default() };
let mut t = GestureTool::new("knife");
// Near the rectangle's corner anchor: the cut starts on it, and the release near the opposite
// corner ends on that one.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 101.0, 99.0).with_mods(alt));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 201.0, 198.0).with_mods(alt));
assert_eq!(overlay_points(&t, &cx), vec![(100.0, 100.0), (200.0, 200.0)]);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 201.0, 198.0).with_mods(alt));
assert_eq!(knife_points(&a), vec![(100.0, 100.0), (200.0, 200.0)]);
assert!(t.overlays(&cx).is_empty());
// Near the top side, away from its anchors: the cut starts on the path.
let acts = drag_held(&mut t, &cx, &[(130.0, 101.5), (260.0, 240.0)], alt);
assert_eq!(knife_points(acts.last().unwrap()), vec![(130.0, 100.0), (260.0, 240.0)]);
// Near the rectangle's center, or in line with its top side, the press stays where it is.
for press in [(151.0, 149.0), (50.0, 101.5)] {
let acts = drag_held(&mut t, &cx, &[press, (260.0, 240.0)], alt);
assert_eq!(knife_points(acts.last().unwrap()), vec![press, (260.0, 240.0)]);
}
// An Alt-click on the snapped press cuts nothing, and neither does a drag back to where the
// pointer went down.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 101.0, 99.0).with_mods(alt));
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 101.0, 99.0).with_mods(alt)), vec![]);
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 101.0, 99.0).with_mods(alt));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 201.0, 198.0).with_mods(alt));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 101.2, 99.0).with_mods(alt));
assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { dashed: true, .. })), "the line goes");
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 101.2, 99.0).with_mods(alt)), vec![]);
// A freehand cut keeps its press.
let acts = drag(&mut t, &cx, &[(101.0, 99.0), (150.0, 120.0), (201.0, 198.0)], Mods::default());
assert_eq!(knife_points(acts.last().unwrap()), vec![(101.0, 99.0), (150.0, 120.0), (201.0, 198.0)]);
}
/// With Smart Guides off, a straight Knife cut starts where it is pressed, with Snap to Point on
/// or off.
#[test]
fn knife_alt_cut_without_smart_guides_keeps_its_press() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
let alt = Mods { alt: true, ..Default::default() };
let mut t = GestureTool::new("knife");
for snap_to_point in [false, true] {
cx.snap_to_point = snap_to_point;
let acts = drag_held(&mut t, &cx, &[(101.0, 99.0), (201.0, 198.0)], alt);
assert_eq!(knife_points(acts.last().unwrap()), vec![(101.0, 99.0), (201.0, 198.0)], "Snap to Point {snap_to_point}");
}
}
/// With Smart Guides on, a straight Knife cut does not start on art in a hidden layer, whose own
/// visibility is still on.
#[test]
fn knife_alt_cut_does_not_snap_to_hidden_art() {
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
d.node_mut(l).unwrap().visible = false;
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let alt = Mods { alt: true, ..Default::default() };
let mut t = GestureTool::new("knife");
let acts = drag_held(&mut t, &cx, &[(101.0, 99.0), (201.0, 198.0)], alt);
assert_eq!(knife_points(acts.last().unwrap()), vec![(101.0, 99.0), (201.0, 198.0)]);
}
/// With Smart Guides on, Shift and Alt keep the Knife's cut at 45° steps from the snapped press,
/// and an anchor near the release does not pull the cut off that step.
#[test]
fn knife_shift_alt_cut_steps_from_the_snapped_press() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let shift_alt = Mods { shift: true, alt: true, ..Default::default() };
let mut t = GestureTool::new("knife");
// Pressed by the bottom-left corner, released by the top-right one.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 101.0, 199.0).with_mods(shift_alt));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 203.0, 104.0).with_mods(shift_alt));
let shown = overlay_points(&t, &cx);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 203.0, 104.0).with_mods(shift_alt));
let cut = knife_points(&a);
assert_eq!(shown, cut);
assert_eq!(cut[0], (100.0, 200.0));
let step = 103f64.hypot(96.0) / 2f64.sqrt();
assert!((cut[1].0 - (100.0 + step)).abs() < 1e-9 && (cut[1].1 - (200.0 - step)).abs() < 1e-9, "{cut:?}");
}
/// The Eraser's Angle and Roundness (Eraser Tool Options) go with its stroke, kept within
/// −180–180° and 1–100%; while it drags, a tip that is not round shows the outline of the area
/// it covers, and a round one the drag as wide as the tip.
+8
View File
@@ -348,6 +348,14 @@ impl DrawSnap {
self.snap(cx, p, from)
}
/// Snap the point pressed at `p` onto the art's anchors and paths alone, within the Smart
/// Guides snapping tolerance (the Knife's straight cut). The targets serve this press only.
pub fn press_on_paths(&mut self, cx: &ToolContext, p: Point) -> Point {
let targets = Targets::for_drawing(cx.doc, &[], visible(cx), None).styled(cx).anchors_and_paths();
self.targets = Some((None, PointSnap { targets: Some((targets, cx.snapping_tolerance)) }));
self.snap(cx, p, None)
}
/// Gather `targets` other than the art for the drag a press starts (a point being dragged
/// leaves itself out); the next hover gathers its own again.
pub fn hold(&mut self, cx: &ToolContext, targets: impl FnOnce() -> Targets) {
+36 -19
View File
@@ -81,11 +81,11 @@ fn within<T>(sorted: &[T], key: impl Fn(&T) -> f64, v: f64, tol: f64) -> &[T] {
sorted.get(lo..hi).unwrap_or_default()
}
/// Visit `nodes` and everything inside them, but the nodes in `skip` and everything inside those:
/// one pass, however many are skipped. A Shaper Group's original shapes, its first child, are
/// not shown and are skipped too.
/// Visit `nodes` and everything inside them, but the nodes in `skip`, the hidden nodes and
/// everything inside those: one pass, however many are skipped. A Shaper Group's original
/// shapes, its first child, are not shown and are skipped too.
fn walk_but<'a>(nodes: &'a [Arc<Node>], skip: &HashSet<NodeId>, f: &mut impl FnMut(&'a Node)) {
for n in nodes.iter().filter(|n| !skip.contains(&n.id)) {
for n in nodes.iter().filter(|n| n.visible && !skip.contains(&n.id)) {
f(n);
if let Some(ch) = n.children() {
walk_but(ch.get(usize::from(n.extras().shaper.is_some())..).unwrap_or_default(), skip, f);
@@ -500,6 +500,19 @@ impl Targets {
self
}
/// Only these targets' anchors and path segments pull, and where segments cross: no centers,
/// bounds, lines up, extensions or glyph guides.
pub(super) fn anchors_and_paths(mut self) -> Self {
self.points.retain(|(_, k)| *k == Kind::Anchor);
self.xs.clear();
self.ys.clear();
self.rulers.clear();
self.boxes.clear();
self.extensions.clear();
self.angled.clear();
self
}
/// View → Snap to Point: only these targets' anchors pull, and the ruler guides (shown and
/// unlocked, as the selection tools pick them) pull the pointer into line where they run.
pub(super) fn for_snap_to_point(self, cx: &ToolContext) -> Self {
@@ -550,23 +563,27 @@ impl Targets {
}
/// Anchors and centres of the visible leaves under `roots` (the roots included) whose bounds
/// reach into `near`.
/// reach into `near`. Nothing inside a hidden group or layer counts.
pub(super) fn points_near(doc: &Document, roots: &[NodeId], near: Rect) -> Self {
let mut t = Targets::default();
for n in roots.iter().filter_map(|id| doc.node(*id)) {
crate::draw2::walk_shown(n, &mut |c| {
if c.is_container() || !c.visible {
return;
}
let Some(b) = c.geometric_bounds() else { return };
if b.x0 > near.x1 || b.x1 < near.x0 || b.y0 > near.y1 || b.y1 < near.y0 {
return;
}
if let NodeKind::Path { path, .. } = &c.kind {
t.points.extend(path.anchors().map(|(_, _, a)| (a.p, Kind::Anchor)));
}
t.points.push((b.center(), Kind::Center));
});
let mut push = |c: &Node| {
if c.is_container() {
return;
}
let Some(b) = c.geometric_bounds() else { return };
if b.x0 > near.x1 || b.x1 < near.x0 || b.y0 > near.y1 || b.y1 < near.y0 {
return;
}
if let NodeKind::Path { path, .. } = &c.kind {
t.points.extend(path.anchors().map(|(_, _, a)| (a.p, Kind::Anchor)));
}
t.points.push((b.center(), Kind::Center));
};
for n in roots.iter().filter_map(|id| doc.node(*id)).filter(|n| n.visible) {
push(n);
if let Some(ch) = n.children() {
walk_but(ch.get(usize::from(n.extras().shaper.is_some())..).unwrap_or_default(), &HashSet::new(), &mut push);
}
}
t.index()
}
+33
View File
@@ -260,6 +260,39 @@ fn shaper_sources_are_no_targets() {
assert!(gives_corner(&Targets::points_near(&d, &[b], near)));
}
/// Art inside a hidden group or layer is no target, even while its own visibility is on: not
/// for drawing, the Knife's straight cut, a move or a transform tool's reference point on the
/// shown group around it.
#[test]
fn art_in_hidden_groups_and_layers_is_no_target() {
use std::sync::Arc;
use vectorcraft_doc::{Appearance, Node};
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let square = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 400.0, 400.0)), Appearance::default_art());
let mut inner = Node::group(d.alloc_id(), vec![Arc::new(square)]);
inner.visible = false;
let outer = Node::group(d.alloc_id(), vec![Arc::new(inner)]);
let g = outer.id;
d.insert(Some(l), 1, outer).unwrap();
let corner = Point::new(300.0, 300.0);
let gives = |t: &Targets, at: Point| {
t.points.iter().any(|(p, _)| *p == at)
|| t.xs.iter().chain(&t.ys).any(|(_, p, _)| *p == at)
|| t.segments.iter().any(|(_, s)| s.start() == at || s.end() == at)
};
assert!(!gives(&Targets::collect(&d, &[], None), corner));
assert!(!gives(&Targets::for_drawing(&d, &[], None, None), corner));
assert!(!gives(&Targets::for_drawing(&d, &[], None, None).anchors_and_paths(), corner));
assert!(!gives(&Targets::points_near(&d, &[g], Rect::new(296.0, 296.0, 304.0, 304.0)), corner));
// The shown rectangle on the same layer still is one, until the layer is hidden.
let shown = Point::new(100.0, 100.0);
assert!(gives(&Targets::for_drawing(&d, &[], None, None).anchors_and_paths(), shown));
d.node_mut(l).unwrap().visible = false;
assert!(!gives(&Targets::collect(&d, &[], None), shown));
assert!(!gives(&Targets::for_drawing(&d, &[], None, None).anchors_and_paths(), shown));
}
/// #2120: the dragged end of a straight segment at any angle lands on the segment's own
/// extension (Line Extension), past its other end or short of it, the guide drawn from that
/// end; with two anchors moving there is none to keep to.
+218 -6
View File
@@ -5,9 +5,10 @@
//! onto the clicked object (`reverse`); Shift+Alt-click: add the clicked object's appearance to the
//! selection's (`append`). Shift-click: sample only the colour under the cursor into the active
//! fill/stroke (`paint.sampleColor`; in a CMYK document an RGB color is stored as CMYK): a
//! gradient's color at that point, a pattern's tile art there (after the pattern's transform), or
//! a gradient mesh's interpolated patch color. Clicking an image samples its pixels (averaged over
//! the options' raster sample size; a CMYK image gives its ink amounts).
//! gradient's color at that point, a pattern's tile art there (after the pattern's transform), a
//! gradient mesh's interpolated patch color, or the art a blend or an envelope draws there (a
//! blend's steps, an envelope's distorted contents). Clicking an image samples its pixels
//! (averaged over the options' raster sample size; a CMYK image gives its ink amounts).
//!
//! The Gradient panel's eyedropper sets the `stop` option (the tool to return to): the next click
//! samples the colour under the cursor into the selected gradient stop (`paint.sampleColor
@@ -119,8 +120,8 @@ pub(crate) fn sample_source(cx: &ToolContext, p: Point) -> Option<(vectorcraft_d
Some((paint_owner(cx.doc, h.leaf), h.kind))
}
/// The visible color under `p`, including image pixels, pattern tiles, mesh colors and
/// intermediate gradient colors.
/// The visible color under `p`, including image pixels, pattern tiles, mesh colors, intermediate
/// gradient colors, the steps a blend draws and an envelope's distorted contents.
pub(crate) fn sample_color_at(cx: &ToolContext, p: Point) -> Option<Color> {
let (doc, opts, raster_sample) = (cx.doc, sample_options(cx), cx.raster_sample);
vectorcraft_doc::depth::with_deep_stack(|| {
@@ -134,7 +135,9 @@ pub(crate) fn sample_color_at(cx: &ToolContext, p: Point) -> Option<Color> {
/// The color the front-most object of `doc` hit at `p` with `opts` shows there. Where a stroke
/// gives no color at `p` (a gap between its pattern's tiles), the object's own fill there does;
/// an object that gives none at all is passed over for the ones below it. `levels` holds the
/// an object that gives none at all is passed over for the ones below it. A hit on a blend or an
/// envelope, or on an object it holds, samples the art it draws ([`live_color_at`]), and where
/// that art leaves `p` empty the whole blend or envelope is passed over. `levels` holds the
/// scratch documents for art inside art, one for each level deeper.
fn color_under(s: &mut Sampler, doc: &Document, levels: &mut [Option<Document>], p: Point, opts: HitOptions) -> Option<Color> {
let mut passed: HashSet<NodeId> = HashSet::new();
@@ -150,6 +153,15 @@ fn color_under(s: &mut Sampler, doc: &Document, levels: &mut [Option<Document>],
};
s.spend(cost)?;
let h = hit_test_visible_skipping(doc, p, opts, &|id| passed.contains(&id))?;
if let Some(live) = drawn_live(doc, &h.ancestry) {
if let Some(c) = live_color_at(s, levels, live, p) {
return Some(c);
}
if !passed.insert(live.id) {
return None;
}
continue;
}
let id = paint_owner(doc, h.leaf);
let n = doc.node(id)?;
let on_stroke = h.kind == HitKind::Stroke;
@@ -177,6 +189,23 @@ fn fill_covers(n: &Node, p: Point) -> bool {
}
}
/// The outermost blend or envelope (other than one whose contents are being edited) in `ancestry`,
/// a hit's path through `doc`: the canvas draws its evaluated art, not the objects it holds.
fn drawn_live<'d>(doc: &'d Document, ancestry: &[NodeId]) -> Option<&'d Node> {
ancestry.iter().filter_map(|id| doc.node(*id)).find(|n| matches!(n.kind, NodeKind::Blend { .. } | NodeKind::Envelope { editing: false, .. }))
}
/// The color the art blend or envelope `n` draws shows at `p`, sampled as [`art_color_at`] samples
/// art: a blend's keys and steps, an envelope's distorted contents, symbol instances in them by
/// their symbols' art. Evaluating it counts the objects it holds, and the art made counts its
/// objects.
fn live_color_at(s: &mut Sampler, levels: &mut [Option<Document>], n: &Node, p: Point) -> Option<Color> {
s.spend(n.count())?;
let art = vectorcraft_effects::expand_live_deep(Some(s.doc), n);
s.spend(art.count())?;
art_color_at(s, levels, [art], p)
}
/// The color `n` shows at `p`: an image's pixels, a mesh's interpolated patch color, a symbol
/// instance's art, or the color of its fill (or its stroke, when the stroke was hit or there is no
/// fill) there. Images, symbols and patterns are looked up in the sampled document.
@@ -1198,4 +1227,187 @@ mod tests {
assert_eq!(paint_color_at(&paint, None, Point::new(0.0, -50.0)).unwrap().to_hex(), "#000000");
assert_eq!(paint_color_at(&paint, None, Point::new(50.0, 0.0)).unwrap().to_hex(), "#808080");
}
/// An unstroked rectangle of `d` filled with `color`.
fn solid_rect(d: &mut vectorcraft_doc::Document, r: vectorcraft_geom::Rect, color: Color) -> Node {
let a = vectorcraft_doc::Appearance::basic(Paint::solid(color), Paint::None, 0.0);
Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(r), a)
}
/// [`doc_with_rect`] plus a green rectangle (290, 290)–(430, 350) and, over it, `live` made by
/// `make` from the document.
fn doc_with_live_over_green(make: impl FnOnce(&mut vectorcraft_doc::Document) -> Node) -> vectorcraft_doc::Document {
use vectorcraft_geom::Rect;
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let green = solid_rect(&mut d, Rect::new(290.0, 290.0, 430.0, 350.0), Color::rgb(0.0, 1.0, 0.0));
d.insert(Some(l), usize::MAX, green).unwrap();
let live = make(&mut d);
d.insert(Some(l), usize::MAX, live).unwrap();
d
}
/// Shift-click on a blend samples the step drawn under the cursor, and a gap between its steps
/// samples what lies below.
#[test]
fn shift_click_samples_the_blend_step_under_the_cursor() {
use vectorcraft_doc::live::{BlendSpacing, BlendSpec};
use vectorcraft_geom::Rect;
// Red (300, 300)–(320, 320) and blue (400, 300)–(420, 320) with 1 step, half red and half
// blue, at x 350 to 370.
let d = doc_with_live_over_green(|d| {
let red = solid_rect(d, Rect::new(300.0, 300.0, 320.0, 320.0), Color::rgb(1.0, 0.0, 0.0));
let blue = solid_rect(d, Rect::new(400.0, 300.0, 420.0, 320.0), Color::rgb(0.0, 0.0, 1.0));
let spec = BlendSpec { spacing: BlendSpacing::Steps(1), ..Default::default() };
Node::new(d.alloc_id(), NodeKind::Blend { children: vec![std::sync::Arc::new(red), std::sync::Arc::new(blue)], spec: Box::new(spec) })
});
let (s, p) = (Selection::default(), paint());
let shift = Mods { shift: true, ..Default::default() };
let at = |x: f64| EyedropperTool::default().pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, x, 310.0).with_mods(shift));
let mid = Color::rgb(0.5, 0.0, 0.5).to_hex();
assert_eq!(sampled_hex(&at(360.0)), mid);
assert_eq!(crate::xform::eyedropper::sample_color_at(&cx(&d, &s, &p), Point::new(360.0, 310.0)).map(|c| c.to_hex()), Some(mid));
assert_eq!(sampled_hex(&at(310.0)), "#ff0000");
// Between the red key and the step (x 320 to 350), and below the step.
assert_eq!(sampled_hex(&at(335.0)), "#00ff00");
let below = EyedropperTool::default().pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 360.0, 325.0).with_mods(shift));
assert_eq!(sampled_hex(&below), "#00ff00");
}
/// A blend of two instances of one symbol draws its steps as instances too: Shift-click on a
/// step samples the symbol's art there, and where that art leaves the point empty, what lies
/// below.
#[test]
fn shift_click_samples_symbol_art_in_a_blends_steps() {
use vectorcraft_doc::live::{BlendSpacing, BlendSpec};
use vectorcraft_geom::{Affine, Rect};
// A red 20 pt square at the symbol's origin, placed at x 300 and 400, with 1 step at x 350.
let d = doc_with_live_over_green(|d| {
let art = solid_rect(d, Rect::new(0.0, 0.0, 20.0, 20.0), Color::rgb(1.0, 0.0, 0.0));
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Dot".into(), art: std::sync::Arc::new(art) });
let mut instance = |x: f64| {
let kind = NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((x, 300.0)) };
std::sync::Arc::new(Node::new(d.alloc_id(), kind))
};
let children = vec![instance(300.0), instance(400.0)];
let spec = BlendSpec { spacing: BlendSpacing::Steps(1), ..Default::default() };
Node::new(d.alloc_id(), NodeKind::Blend { children, spec: Box::new(spec) })
});
let (s, p) = (Selection::default(), paint());
let sample = |x: f64, y: f64| crate::xform::eyedropper::sample_color_at(&cx(&d, &s, &p), Point::new(x, y)).map(|c| c.to_hex());
assert_eq!(sample(360.0, 310.0), Some("#ff0000".into()));
assert_eq!(sample(335.0, 310.0), Some("#00ff00".into()));
assert_eq!(sample(360.0, 325.0), Some("#00ff00".into()));
}
/// Align to Path turns a blend's keys and steps to its spine, past the keys' bounds: a click
/// there hits the blend and samples what it draws.
#[test]
fn shift_click_samples_blend_art_turned_past_its_bounds() {
use vectorcraft_doc::live::{BlendOrientation, BlendSpacing, BlendSpec};
use vectorcraft_geom::Rect;
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let green = solid_rect(&mut d, Rect::new(20.0, 20.0, 280.0, 280.0), Color::rgb(0.0, 1.0, 0.0));
d.insert(Some(l), usize::MAX, green).unwrap();
// 100 × 10 keys centred on (100, 100) and (200, 200): Align to Path turns them 45°, and the
// red one reaches (70, 66), above the keys' bounds (y 95 to 205).
let red = solid_rect(&mut d, Rect::new(50.0, 95.0, 150.0, 105.0), Color::rgb(1.0, 0.0, 0.0));
let blue = solid_rect(&mut d, Rect::new(150.0, 195.0, 250.0, 205.0), Color::rgb(0.0, 0.0, 1.0));
let spec = BlendSpec { spacing: BlendSpacing::Steps(1), orientation: BlendOrientation::AlignToPath, ..Default::default() };
let id = d.alloc_id();
let blend = Node::new(id, NodeKind::Blend { children: vec![std::sync::Arc::new(red), std::sync::Arc::new(blue)], spec: Box::new(spec) });
d.insert(Some(l), usize::MAX, blend).unwrap();
let (s, p) = (Selection::default(), paint());
let at = Point::new(70.0, 66.0);
assert!(d.node(id).unwrap().geometric_bounds().is_some_and(|b| !b.contains(at)));
assert_eq!(hit_test(&d, at, cx(&d, &s, &p).hit_options()).map(|h| h.leaf), Some(id));
assert_eq!(crate::xform::eyedropper::sample_color_at(&cx(&d, &s, &p), at).map(|c| c.to_hex()), Some("#ff0000".into()));
// Where the turned key leaves the point empty, the click passes the blend by.
assert_eq!(crate::xform::eyedropper::sample_color_at(&cx(&d, &s, &p), Point::new(60.0, 90.0)).map(|c| c.to_hex()), Some("#00ff00".into()));
// A hidden red key does not hit there.
if let Some(NodeKind::Blend { children, .. }) = d.node_mut(id).map(|n| &mut n.kind) {
std::sync::Arc::make_mut(&mut children[0]).visible = false;
}
assert_eq!(hit_test(&d, at, cx(&d, &s, &p).hit_options()).map(|h| h.leaf), Some(d.layers[0].children().unwrap()[1].id));
// A red key clipped to its right half, centred on (125, 100): the line to the blue key's
// centre turns it about 53°, and its hidden left half reaches (89, 52).
let mut clip = solid_rect(&mut d, Rect::new(100.0, 95.0, 150.0, 105.0), Color::BLACK);
clip.appearance = Default::default();
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
let red = solid_rect(&mut d, Rect::new(50.0, 95.0, 150.0, 105.0), Color::rgb(1.0, 0.0, 0.0));
let key = Node::new(d.alloc_id(), NodeKind::Group { children: vec![std::sync::Arc::new(clip), std::sync::Arc::new(red)], clip: true });
if let Some(NodeKind::Blend { children, .. }) = d.node_mut(id).map(|n| &mut n.kind) {
children[0] = std::sync::Arc::new(key);
}
let hidden = Point::new(89.0, 52.0);
assert!(d.node(id).unwrap().geometric_bounds().is_some_and(|b| !b.contains(hidden)));
assert_eq!(hit_test(&d, hidden, cx(&d, &s, &p).hit_options()).map(|h| h.leaf), Some(d.layers[0].children().unwrap()[1].id));
assert_eq!(crate::xform::eyedropper::sample_color_at(&cx(&d, &s, &p), hidden).map(|c| c.to_hex()), Some("#00ff00".into()));
}
/// The stroke of an envelope's distorted contents paints past the envelope's bounds: a click
/// there hits the envelope and samples the stroke.
#[test]
fn shift_click_samples_an_envelopes_stroke_past_its_bounds() {
use vectorcraft_geom::{Affine, Rect};
let mut id = None;
let d = doc_with_live_over_green(|d| {
let a = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 10.0);
let red = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 420.0, 320.0)), a);
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true };
let content = vec![std::sync::Arc::new(red)];
let n = Node::new(
d.alloc_id(),
NodeKind::Envelope {
content,
kind: Box::new(kind),
fidelity: 50.0,
editing: false,
options: Default::default(),
frame: Box::new(Affine::IDENTITY),
},
);
id = Some(n.id);
n
});
let (s, p) = (Selection::default(), paint());
// The middle of the band's top edge stays at y 300, and its 10 pt stroke reaches y 295.
let at = Point::new(360.0, 296.0);
assert!(d.node(id.unwrap()).unwrap().geometric_bounds().is_some_and(|b| !b.inflate(3.0, 3.0).contains(at)));
assert_eq!(hit_test(&d, at, cx(&d, &s, &p).hit_options()).map(|h| h.leaf), id);
assert_eq!(crate::xform::eyedropper::sample_color_at(&cx(&d, &s, &p), at).map(|c| c.to_hex()), Some("#0000ff".into()));
}
/// Shift-click on an envelope samples its distorted contents where they are drawn, and a point
/// in its bounds that they leave empty samples what lies below.
#[test]
fn shift_click_samples_the_envelopes_distorted_contents() {
use vectorcraft_geom::{Affine, Rect};
let d = doc_with_live_over_green(|d| {
let red = solid_rect(d, Rect::new(300.0, 300.0, 420.0, 320.0), Color::rgb(1.0, 0.0, 0.0));
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true };
let content = vec![std::sync::Arc::new(red)];
let options = Default::default();
Node::new(
d.alloc_id(),
NodeKind::Envelope { content, kind: Box::new(kind), fidelity: 50.0, editing: false, options, frame: Box::new(Affine::IDENTITY) },
)
});
let (s, p) = (Selection::default(), paint());
let sample = |x: f64, y: f64| crate::xform::eyedropper::sample_color_at(&cx(&d, &s, &p), Point::new(x, y)).map(|c| c.to_hex());
let art = vectorcraft_effects::expand_live_deep(Some(&d), d.layers[0].children().unwrap().last().unwrap());
let b = art.geometric_bounds().unwrap();
// The arc bends the ends of the band down, below the 20 pt it was, and keeps its middle.
assert!(b.y1 > 330.0, "{b:?}");
assert_eq!(sample(360.0, 310.0), Some("#ff0000".into()));
assert_eq!(sample(310.0, 332.0), Some("#ff0000".into()));
// Only what the art paints counts: just past the bent end's edge, within the click
// tolerance of it, the green below shows.
assert_eq!(sample(305.0, 332.0), Some("#00ff00".into()));
assert_eq!(sample(360.0, 332.0), Some("#00ff00".into()));
assert_eq!(sample(b.x0 + 0.5, b.y0 + 1.0), Some("#00ff00".into()));
}
}
+28 -1
View File
@@ -1,7 +1,8 @@
//! Document Tools in the interface: its submenu (Object › Plug-ins › Document Tools), which runs
//! Save a Timestamped Copy, New Symbols from Layers and Convert Layers to Symbols (the same, with
//! each layer's art replaced by an instance). Save a Timestamped Copy of a document never saved
//! asks for the folder to put it in ([`save_copy`]).
//! asks for the folder to put it in ([`save_copy`]). Export All Open Documents as PDF or SVG asks
//! for the folder the copies go in ([`export_all`], #4920).
use serde_json::{Value, json};
@@ -10,6 +11,8 @@ use crate::menus::Item;
/// Save a Timestamped Copy.
pub const SAVE_COPY: &str = "addon.document.saveTimestampedCopy";
/// Export All Open Documents.
pub const EXPORT_ALL: &str = "addon.document.exportAllOpen";
/// New Symbols from Layers (Convert Layers to Symbols: with their art replaced by instances).
const SYMBOLS: &str = "addon.document.symbolsFromLayers";
@@ -17,6 +20,8 @@ const SYMBOLS: &str = "addon.document.symbolsFromLayers";
pub fn menu() -> Vec<Item> {
vec![
Item::Cmd("Save a Timestamped Copy", SAVE_COPY, json!({})),
Item::Cmd("Export All Open Documents as PDF", EXPORT_ALL, json!({ "format": "pdf" })),
Item::Cmd("Export All Open Documents as SVG", EXPORT_ALL, json!({ "format": "svg" })),
Item::Sep,
Item::Cmd("New Symbols from Layers", SYMBOLS, json!({})),
Item::Cmd("Convert Layers to Symbols", SYMBOLS, json!({ "replace": true })),
@@ -35,5 +40,27 @@ pub(crate) fn save_copy(app: &mut VectorcraftApp, p: &Value) -> Result<Value, St
Ok(r)
}
/// Export All Open Documents with params `p`: without a `folder`, the copies go in the folder
/// picked. The status bar says how many were written and names the first that failed.
pub(crate) fn export_all(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let mut p = if p.is_object() { p.clone() } else { json!({}) };
if p.get("folder").is_none() {
p["folder"] = json!(crate::picks::folder(app).ok_or("cancelled")?);
}
let r = app.session.execute(EXPORT_ALL, &p).map_err(|e| e.to_string())?;
let written = r["files"].as_array().map_or(0, Vec::len);
let failed = r["failed"].as_array().cloned().unwrap_or_default();
let status = match failed.first() {
Some(f) => {
let args = [("title", f["title"].as_str().unwrap_or_default()), ("error", f["error"].as_str().unwrap_or_default())];
let (one, other) = ("Exported {n} document; {title} wasn't written: {error}", "Exported {n} documents; {title} wasn't written: {error}");
crate::i18n::msg_n(written as u64, one, other, &args)
}
None => crate::i18n::msg_n(written as u64, "Exported {n} document", "Exported {n} documents", &[]),
};
app.status(status);
Ok(r)
}
#[cfg(test)]
mod tests;
@@ -82,3 +82,44 @@ fn new_symbols_from_layers_and_convert_layers_to_symbols() {
.all(|l| matches!(l.children().unwrap()[..], [ref one] if matches!(one.kind, vectorcraft_doc::NodeKind::SymbolInstance { .. })))
);
}
/// #4920: Export All Open Documents as PDF asks for a folder and writes one PDF per open document,
/// named after each, without replacing a file there, and leaves every document as it was.
#[test]
fn export_all_open_documents_writes_each_to_the_folder_picked() {
let dir = folder("export-all");
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
app.run("document.save", json!({"path": dir.join("card.vectorcraft")})).unwrap();
app.run("file.new", json!({"width": 200, "height": 100, "name": "card"})).unwrap();
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let untitled = app.session.doc().unwrap().doc.title.clone();
let out = dir.join("out");
std::fs::create_dir_all(&out).unwrap();
std::fs::write(out.join(format!("{untitled}.pdf")), b"keep").unwrap();
let picked = out.to_string_lossy().into_owned();
app.services.pick_folder = Some(Box::new(move || Some(picked.clone())));
let active = app.session.active_index();
click(&mut app, "Export All Open Documents as PDF");
let mut names = files(&out);
names.sort();
let mut want = vec!["card.pdf".to_string(), "card-2.pdf".into(), format!("{untitled}.pdf"), format!("{untitled}-2.pdf")];
want.sort();
assert_eq!(names, want, "one per document, the taken names numbered");
assert_eq!(std::fs::read(out.join(format!("{untitled}.pdf"))).unwrap(), b"keep", "no file is replaced");
assert!(std::fs::read(out.join("card.pdf")).unwrap().starts_with(b"%PDF"));
assert_eq!(app.session.active_index(), active, "the active document stays active");
assert!(
app.session.documents().iter().all(|d| d.path.as_ref().is_none_or(|p| p.ends_with("card.vectorcraft"))),
"no document takes a copy's path"
);
assert!(app.ui.status.starts_with("Exported 3 documents"), "{}", app.ui.status);
// Cancelled: nothing written.
app.services.pick_folder = Some(Box::new(|| None));
assert_eq!(app.run(super::EXPORT_ALL, json!({})).unwrap_err(), "cancelled");
assert_eq!(files(&out).len(), 4);
// The engine command refuses a missing folder and an unknown format.
assert!(app.session.execute(super::EXPORT_ALL, &json!({})).is_err());
assert!(app.session.execute(super::EXPORT_ALL, &json!({"folder": out, "format": "doc"})).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
+2 -1
View File
@@ -1,6 +1,6 @@
//! Path Precision Tools in the interface: its submenu (Object › Plug-ins › Path Precision Tools),
//! which selects its tools, opens its panels and runs Split / Add Points at Crossings, Divide into
//! Dashes ([`dashes`]) and Convert to Parametric Shape; the Anchor Inspector panel (the selected
//! Dashes ([`dashes`]), Expand Dashes into Paths and Convert to Parametric Shape; the Anchor Inspector panel (the selected
//! anchor's position and handles as numbers, the add-on's anchor edits, the Corner Tool's values,
//! [`corner`], and the Shared Edge Merge Tool's, [`shared_edge`]); the Parametric Shape panel
//! ([`shape`]); and its tools' options dialog (a double-click on the tool). Everything acts through `addon.pathtools.*` and
@@ -65,6 +65,7 @@ pub fn menu() -> Vec<Item> {
Item::Cmd("Add Points at Crossings", "addon.pathtools.crossings", json!({})),
Item::Cmd("Split at Crossings", "addon.pathtools.crossings", json!({ "cut": true })),
Item::Cmd("Divide into Dashes…", dashes::COMMAND, Value::Null),
Item::Cmd("Expand Dashes into Paths", "addon.pathtools.expandDashes", json!({})),
Item::Cmd("Convert to Parametric Shape", "addon.pathtools.shape.convert", json!({})),
]
}
@@ -33,6 +33,16 @@ fn the_plugins_submenu_selects_the_tools_and_opens_the_anchor_inspector() {
assert_eq!(e.command.as_deref(), Some("addon.pathtools.crossings"));
assert_eq!(e.params.get("cut").and_then(|v| v.as_bool()).unwrap_or(false), cut);
}
// Expand Dashes into Paths runs at once: a dashed line becomes its dashes.
let line = app.run("path.create", json!({"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}]})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [line]})).unwrap();
app.run("stroke.set", json!({"dash": [30, 10]})).unwrap();
let expand = under("Expand Dashes into Paths").expect("listed");
let (cmd, p) = menus::click_target(&expand.label, expand.command.as_deref().unwrap(), &expand.params);
menus::invoke(&mut app, &cmd, p);
let selected = app.session.doc().unwrap().selection.objects.len();
assert!(selected >= 3, "three dashes selected: {selected}");
app.run("edit.undo", json!({})).unwrap();
// Artwork Cleanup's submenu and Install Plug-in… are still there.
assert!(entries.iter().any(|e| e.path == ["Object", "Plug-ins", "Artwork Cleanup"]));
assert!(entries.iter().any(|e| e.path == ["Object", "Plug-ins"] && e.label == "Install Plug-in…"));
+11
View File
@@ -5935,6 +5935,9 @@ breakText Glyphs Глифи
Gap 4 Разстояние 4
Dash 5 Тире 5
Gap 5 Разстояние 5
@msg {_1}: they came in as the page's art {_1}: те са внесени като изображението от страницата
@msg You must choose a side pattern. Трябва да изберете страничен шаблон.
@msg invalid pattern tile choice невалиден избор на плочка за шаблон
@msg no pattern named `{choice}` няма шаблон с име `{choice}`
@@ -5965,6 +5968,9 @@ breakText Glyphs Глифи
Remove Gaps Премахване на разстоянията
@msg that makes too many pieces (at most 100000): make the dashes longer така се получават твърде много части (най-много 100000): удължете тиретата
@msg select paths, or segments, to divide изберете пътища или сегменти за разделяне
# Path Precision Tools add-on: Expand Dashes into Paths.
Expand Dashes into Paths Разширяване на тиретата в пътища
@msg select paths with dashed strokes to expand изберете пътища с прекъснат щрих за разширяване
# Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy.
Document Tools Инструменти за документи
Save a Timestamped Copy Запазване на копие с дата и час
@@ -5987,6 +5993,11 @@ breakText Glyphs Глифи
Odia Ория
Urdu Урду
VectorCraft {release} and later (version {n}) VectorCraft {release} и по-нови (версия {n})
# Document Tools add-on: Export All Open Documents (#4920).
Export All Open Documents as PDF Експортиране на всички отворени документи като PDF
Export All Open Documents as SVG Експортиране на всички отворени документи като SVG
@msgplural Exported {n} document|Exported {n} documents Експортиран е {n} документ|Експортирани са {n} документа
@msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} Експортиран е {n} документ; {title} не беше записан: {error}|Експортирани са {n} документа; {title} не беше записан: {error}
# Symbol Options › Symbol Type (dynamic symbols)
Symbol Type: Тип на символа:
Dynamic Symbol Динамичен символ
Can't render this file because it contains an unexpected character in line 2 and column 73.
+10
View File
@@ -5968,6 +5968,8 @@ shape Open Otvoren
@msg `follow` must be "average", "a" or "b" `follow` mora biti "average", "a" ili "b"
@msg the paths have nothing to merge there putanje tu nemaju šta spojiti
@msg the path is locked or hidden putanja je zaključana ili skrivena
@msg {_1}: they came in as the page's art {_1}: uvezeni su kao ilustracija stranice
@msg You must choose a side pattern. Morate odabrati bočni uzorak.
@msg invalid pattern tile choice nevažeći izbor pločice uzorka
@msg no pattern named `{choice}` nema uzorka pod nazivom `{choice}`
@@ -5998,6 +6000,9 @@ shape Open Otvoren
Remove Gaps Ukloni razmake
@msg that makes too many pieces (at most 100000): make the dashes longer to daje previše dijelova (najviše 100000): produžite crtice
@msg select paths, or segments, to divide odaberite putanje ili segmente za dijeljenje
# Path Precision Tools add-on: Expand Dashes into Paths.
Expand Dashes into Paths Proširi crtice u putanje
@msg select paths with dashed strokes to expand odaberite putanje s isprekidanim potezom za proširivanje
# Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy.
Document Tools Alati za dokument
Save a Timestamped Copy Sačuvaj kopiju s datumom i vremenom
@@ -6020,6 +6025,11 @@ shape Open Otvoren
Odia Odija
Urdu Urdu
VectorCraft {release} and later (version {n}) VectorCraft {release} i novije (verzija {n})
# Document Tools add-on: Export All Open Documents (#4920).
Export All Open Documents as PDF Izvezi sve otvorene dokumente kao PDF
Export All Open Documents as SVG Izvezi sve otvorene dokumente kao SVG
@msgplural Exported {n} document|Exported {n} documents Izvezen je {n} dokument|Izvezena su {n} dokumenta|Izvezeno je {n} dokumenata
@msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} Izvezen je {n} dokument; {title} nije zapisan: {error}|Izvezena su {n} dokumenta; {title} nije zapisan: {error}|Izvezeno je {n} dokumenata; {title} nije zapisan: {error}
# Symbol Options › Symbol Type (dynamic symbols)
Symbol Type: Vrsta simbola:
Dynamic Symbol Dinamički simbol
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -6039,6 +6039,9 @@ breakText Glyphs Glifs
Gap 4 Separació 4
Dash 5 Guió 5
Gap 5 Separació 5
@msg {_1}: they came in as the page's art {_1}: han entrat com a art de la pàgina
@msg the JPEG 2000 file can't be read no es pot llegir el fitxer JPEG 2000
@msg the JPEG 2000 file has an unsupported color space or JPX content el fitxer JPEG 2000 té un espai de color o contingut JPX no compatible
@msg You must choose a side pattern. Heu de triar un patró lateral.
@@ -6071,6 +6074,9 @@ breakText Glyphs Glifs
Remove Gaps Elimina les separacions
@msg that makes too many pieces (at most 100000): make the dashes longer això fa massa trossos (com a màxim 100000): allargueu els guions
@msg select paths, or segments, to divide seleccioneu camins o segments per dividir
# Path Precision Tools add-on: Expand Dashes into Paths.
Expand Dashes into Paths Expandeix els guions en camins
@msg select paths with dashed strokes to expand seleccioneu camins amb traç discontinu per expandir
# Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy.
Document Tools Eines del document
Save a Timestamped Copy Desa una còpia amb data i hora
@@ -6093,6 +6099,11 @@ breakText Glyphs Glifs
Odia Oriya
Urdu Urdú
VectorCraft {release} and later (version {n}) VectorCraft {release} i posteriors (versió {n})
# Document Tools add-on: Export All Open Documents (#4920).
Export All Open Documents as PDF Exporta tots els documents oberts com a PDF
Export All Open Documents as SVG Exporta tots els documents oberts com a SVG
@msgplural Exported {n} document|Exported {n} documents S’ha exportat {n} document|S’han exportat {n} documents
@msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} S’ha exportat {n} document; no s’ha escrit {title}: {error}|S’han exportat {n} documents; no s’ha escrit {title}: {error}
# Symbol Options › Symbol Type (dynamic symbols)
Symbol Type: Tipus de símbol:
Dynamic Symbol Símbol dinàmic
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -6051,6 +6051,9 @@ breakText Glyphs Glyfy
Gap 4 Mezera 4
Dash 5 Čárka 5
Gap 5 Mezera 5
@msg {_1}: they came in as the page's art {_1}: byly načteny jako kresba ze stránky
@msg You must choose a side pattern. Musíte zvolit boční vzorek.
@msg invalid pattern tile choice neplatná volba dlaždice vzorku
@msg no pattern named `{choice}` vzorek s názvem `{choice}` neexistuje
@@ -6081,6 +6084,9 @@ breakText Glyphs Glyfy
Remove Gaps Odstranit mezery
@msg that makes too many pieces (at most 100000): make the dashes longer tím vznikne příliš mnoho dílů (nejvýše 100000): prodlužte čárky
@msg select paths, or segments, to divide vyberte cesty nebo segmenty k rozdělení
# Path Precision Tools add-on: Expand Dashes into Paths.
Expand Dashes into Paths Rozložit čárky na cesty
@msg select paths with dashed strokes to expand vyberte cesty s čárkovaným tahem k rozložení
# Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy.
Document Tools Nástroje dokumentu
Save a Timestamped Copy Uložit kopii s datem a časem
@@ -6103,6 +6109,11 @@ breakText Glyphs Glyfy
Odia Urijština
Urdu Urdština
VectorCraft {release} and later (version {n}) VectorCraft {release} a novější (verze {n})
# Document Tools add-on: Export All Open Documents (#4920).
Export All Open Documents as PDF Exportovat všechny otevřené dokumenty jako PDF
Export All Open Documents as SVG Exportovat všechny otevřené dokumenty jako SVG
@msgplural Exported {n} document|Exported {n} documents Exportován {n} dokument|Exportovány {n} dokumenty|Exportováno {n} dokumentů
@msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} Exportován {n} dokument; {title} nebyl zapsán: {error}|Exportovány {n} dokumenty; {title} nebyl zapsán: {error}|Exportováno {n} dokumentů; {title} nebyl zapsán: {error}
# Symbol Options › Symbol Type (dynamic symbols)
Symbol Type: Typ symbolu:
Dynamic Symbol Dynamický symbol
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5950,6 +5950,9 @@ breakText Glyphs Glyffer
Gap 4 Mellemrum 4
Dash 5 Streg 5
Gap 5 Mellemrum 5
@msg {_1}: they came in as the page's art {_1}: de blev importeret som sidens grafik
@msg You must choose a side pattern. Du skal vælge et sidemønster.
@msg invalid pattern tile choice ugyldigt valg af mønsterflise
@msg no pattern named `{choice}` intet mønster med navnet `{choice}`
@@ -5980,6 +5983,9 @@ breakText Glyphs Glyffer
Remove Gaps Fjern mellemrum
@msg that makes too many pieces (at most 100000): make the dashes longer det giver for mange stykker (højst 100000): gør stregerne længere
@msg select paths, or segments, to divide vælg stier eller segmenter, der skal opdeles
# Path Precision Tools add-on: Expand Dashes into Paths.
Expand Dashes into Paths Udvid streger til stier
@msg select paths with dashed strokes to expand vælg stier med stiplet streg, der skal udvides
# Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy.
Document Tools Dokumentværktøjer
Save a Timestamped Copy Gem en kopi med dato og klokkeslæt
@@ -6002,6 +6008,11 @@ breakText Glyphs Glyffer
Odia Oriya
Urdu Urdu
VectorCraft {release} and later (version {n}) VectorCraft {release} og nyere (version {n})
# Document Tools add-on: Export All Open Documents (#4920).
Export All Open Documents as PDF Eksportér alle åbne dokumenter som PDF
Export All Open Documents as SVG Eksportér alle åbne dokumenter som SVG
@msgplural Exported {n} document|Exported {n} documents Eksporterede {n} dokument|Eksporterede {n} dokumenter
@msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} Eksporterede {n} dokument; {title} blev ikke skrevet: {error}|Eksporterede {n} dokumenter; {title} blev ikke skrevet: {error}
# Symbol Options › Symbol Type (dynamic symbols)
Symbol Type: Symboltype:
Dynamic Symbol Dynamisk symbol
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5636,6 +5636,9 @@ breakText Glyphs Glyphen
Gap 4 Lücke 4
Dash 5 Strich 5
Gap 5 Lücke 5
@msg {_1}: they came in as the page's art {_1}: sie wurden als Seitengrafik importiert
@msg You must choose a side pattern. Sie müssen ein Seitenmuster auswählen.
@msg invalid pattern tile choice ungültige Auswahl einer Musterkachel
@msg no pattern named `{choice}` kein Muster mit dem Namen `{choice}`
@@ -5666,6 +5669,9 @@ breakText Glyphs Glyphen
Remove Gaps Lücken entfernen
@msg that makes too many pieces (at most 100000): make the dashes longer das ergibt zu viele Teile (höchstens 100000): machen Sie die Striche länger
@msg select paths, or segments, to divide wählen Sie Pfade oder Segmente zum Aufteilen aus
# Path Precision Tools add-on: Expand Dashes into Paths.
Expand Dashes into Paths Striche in Pfade umwandeln
@msg select paths with dashed strokes to expand wählen Sie Pfade mit gestrichelter Kontur zum Umwandeln aus
# Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy.
Document Tools Dokumentwerkzeuge
Save a Timestamped Copy Kopie mit Zeitstempel speichern
@@ -5688,6 +5694,11 @@ breakText Glyphs Glyphen
Odia Oriya
Urdu Urdu
VectorCraft {release} and later (version {n}) VectorCraft {release} und neuer (Version {n})
# Document Tools add-on: Export All Open Documents (#4920).
Export All Open Documents as PDF Alle geöffneten Dokumente als PDF exportieren
Export All Open Documents as SVG Alle geöffneten Dokumente als SVG exportieren
@msgplural Exported {n} document|Exported {n} documents {n} Dokument exportiert|{n} Dokumente exportiert
@msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} {n} Dokument exportiert; {title} wurde nicht geschrieben: {error}|{n} Dokumente exportiert; {title} wurde nicht geschrieben: {error}
# Symbol Options › Symbol Type (dynamic symbols)
Symbol Type: Symboltyp:
Dynamic Symbol Dynamisches Symbol
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -6003,6 +6003,9 @@ breakText Glyphs Γλύφοι
Gap 4 Διάκενο 4
Dash 5 Παύλα 5
Gap 5 Διάκενο 5
@msg {_1}: they came in as the page's art {_1}: εισήχθησαν ως γραφικά της σελίδας
@msg You must choose a side pattern. Πρέπει να επιλέξετε πλευρικό μοτίβο.
@msg invalid pattern tile choice μη έγκυρη επιλογή πλακιδίου μοτίβου
@msg no pattern named `{choice}` δεν υπάρχει μοτίβο με όνομα `{choice}`
@@ -6033,6 +6036,9 @@ breakText Glyphs Γλύφοι
Remove Gaps Αφαίρεση κενών
@msg that makes too many pieces (at most 100000): make the dashes longer αυτό δίνει πάρα πολλά κομμάτια (το πολύ 100000): κάντε τις παύλες μεγαλύτερες
@msg select paths, or segments, to divide επιλέξτε διαδρομές ή τμήματα για διαίρεση
# Path Precision Tools add-on: Expand Dashes into Paths.
Expand Dashes into Paths Επέκταση παυλών σε διαδρομές
@msg select paths with dashed strokes to expand επιλέξτε διαδρομές με διακεκομμένο περίγραμμα για επέκταση
# Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy.
Document Tools Εργαλεία εγγράφου
Save a Timestamped Copy Αποθήκευση αντιγράφου με ημερομηνία και ώρα
@@ -6055,6 +6061,11 @@ breakText Glyphs Γλύφοι
Odia Όντια
Urdu Ούρντου
VectorCraft {release} and later (version {n}) VectorCraft {release} και νεότερες (έκδοση {n})
# Document Tools add-on: Export All Open Documents (#4920).
Export All Open Documents as PDF Εξαγωγή όλων των ανοιχτών εγγράφων ως PDF
Export All Open Documents as SVG Εξαγωγή όλων των ανοιχτών εγγράφων ως SVG
@msgplural Exported {n} document|Exported {n} documents Εξήχθη {n} έγγραφο|Εξήχθησαν {n} έγγραφα
@msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} Εξήχθη {n} έγγραφο; το {title} δεν γράφτηκε: {error}|Εξήχθησαν {n} έγγραφα; το {title} δεν γράφτηκε: {error}
# Symbol Options › Symbol Type (dynamic symbols)
Symbol Type: Τύπος συμβόλου:
Dynamic Symbol Δυναμικό σύμβολο
Can't render this file because it contains an unexpected character in line 164 and column 5.
+11
View File
@@ -5993,6 +5993,9 @@ breakText Glyphs Glifos
Gap 4 Hueco 4
Dash 5 Guion 5
Gap 5 Hueco 5
@msg {_1}: they came in as the page's art {_1}: se importaron como el arte de la página
@msg You must choose a side pattern. Debe elegir un motivo lateral.
@msg invalid pattern tile choice selección de mosaico de motivo no válida
@msg no pattern named `{choice}` no hay ningún motivo llamado `{choice}`
@@ -6023,6 +6026,9 @@ breakText Glyphs Glifos
Remove Gaps Quitar separaciones
@msg that makes too many pieces (at most 100000): make the dashes longer eso da demasiados trozos (como máximo 100000): alargue los guiones
@msg select paths, or segments, to divide seleccione trazados o segmentos para dividir
# Path Precision Tools add-on: Expand Dashes into Paths.
Expand Dashes into Paths Expandir guiones en trazados
@msg select paths with dashed strokes to expand seleccione trazados con trazo discontinuo para expandir
# Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy.
Document Tools Herramientas de documento
Save a Timestamped Copy Guardar una copia con fecha y hora
@@ -6045,6 +6051,11 @@ breakText Glyphs Glifos
Odia Oriya
Urdu Urdu
VectorCraft {release} and later (version {n}) VectorCraft {release} y posteriores (versión {n})
# Document Tools add-on: Export All Open Documents (#4920).
Export All Open Documents as PDF Exportar todos los documentos abiertos como PDF
Export All Open Documents as SVG Exportar todos los documentos abiertos como SVG
@msgplural Exported {n} document|Exported {n} documents Se exportó {n} documento|Se exportaron {n} documentos
@msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} Se exportó {n} documento; no se escribió {title}: {error}|Se exportaron {n} documentos; no se escribió {title}: {error}
# Symbol Options › Symbol Type (dynamic symbols)
Symbol Type: Tipo de símbolo:
Dynamic Symbol Símbolo dinámico
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -6081,6 +6081,9 @@ breakText Glyphs Merkit
Gap 4 Väli 4
Dash 5 Viiva 5
Gap 5 Väli 5
@msg {_1}: they came in as the page's art {_1}: ne tuotiin sivun piirroksena
@msg You must choose a side pattern. Valitse sivukuvio.
@msg invalid pattern tile choice virheellinen kuviolaatan valinta
@msg no pattern named `{choice}` kuviota nimeltä `{choice}` ei löydy
@@ -6111,6 +6114,9 @@ breakText Glyphs Merkit
Remove Gaps Poista välit
@msg that makes too many pieces (at most 100000): make the dashes longer siitä tulee liian monta osaa (enintään 100000): pidennä viivoja
@msg select paths, or segments, to divide valitse jaettavat polut tai segmentit
# Path Precision Tools add-on: Expand Dashes into Paths.
Expand Dashes into Paths Laajenna viivat poluiksi
@msg select paths with dashed strokes to expand valitse laajennettavat polut, joissa on katkoviiva
# Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy.
Document Tools Asiakirjatyökalut
Save a Timestamped Copy Tallenna kopio aikaleimalla
@@ -6133,6 +6139,11 @@ breakText Glyphs Merkit
Odia Orija
Urdu Urdu
VectorCraft {release} and later (version {n}) VectorCraft {release} ja uudemmat (versio {n})
# Document Tools add-on: Export All Open Documents (#4920).
Export All Open Documents as PDF Vie kaikki avoimet asiakirjat PDF-muodossa
Export All Open Documents as SVG Vie kaikki avoimet asiakirjat SVG-muodossa
@msgplural Exported {n} document|Exported {n} documents Vietiin {n} asiakirja|Vietiin {n} asiakirjaa
@msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} Vietiin {n} asiakirja; {title} jäi kirjoittamatta: {error}|Vietiin {n} asiakirjaa; {title} jäi kirjoittamatta: {error}
# Symbol Options › Symbol Type (dynamic symbols)
Symbol Type: Symbolin tyyppi:
Dynamic Symbol Dynaaminen symboli
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -6048,6 +6048,9 @@ breakText Glyphs Glyphes
Gap 4 Écart 4
Dash 5 Tiret 5
Gap 5 Écart 5
@msg {_1}: they came in as the page's art {_1} : ils ont été importés comme le dessin de la page
@msg You must choose a side pattern. Vous devez choisir un motif latéral.
@msg invalid pattern tile choice choix de tuile de motif non valide
@msg no pattern named `{choice}` aucun motif nommé `{choice}`
@@ -6078,6 +6081,9 @@ breakText Glyphs Glyphes
Remove Gaps Supprimer les écarts
@msg that makes too many pieces (at most 100000): make the dashes longer cela donne trop de morceaux (100000 au plus) : allongez les tirets
@msg select paths, or segments, to divide sélectionnez des tracés ou des segments à diviser
# Path Precision Tools add-on: Expand Dashes into Paths.
Expand Dashes into Paths Décomposer les tirets en tracés
@msg select paths with dashed strokes to expand sélectionnez des tracés à contour pointillé à décomposer
# Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy.
Document Tools Outils de document
Save a Timestamped Copy Enregistrer une copie horodatée
@@ -6100,6 +6106,11 @@ breakText Glyphs Glyphes
Odia Odia
Urdu Ourdou
VectorCraft {release} and later (version {n}) VectorCraft {release} et ultérieures (version {n})
# Document Tools add-on: Export All Open Documents (#4920).
Export All Open Documents as PDF Exporter tous les documents ouverts en PDF
Export All Open Documents as SVG Exporter tous les documents ouverts en SVG
@msgplural Exported {n} document|Exported {n} documents {n} document exporté|{n} documents exportés
@msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} {n} document exporté; {title} n’a pas été écrit : {error}|{n} documents exportés; {title} n’a pas été écrit : {error}
# Symbol Options › Symbol Type (dynamic symbols)
Symbol Type: Type de symbole :
Dynamic Symbol Symbole dynamique
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -6037,6 +6037,9 @@ breakText Glyphs Glifák
Gap 4 Hézag 4
Dash 5 Vonal 5
Gap 5 Hézag 5
@msg {_1}: they came in as the page's art {_1}: az oldal rajzaként lettek importálva
@msg You must choose a side pattern. Válasszon oldalsó mintát.
@msg invalid pattern tile choice érvénytelen mintacsempe-választás
@msg no pattern named `{choice}` nincs `{choice}` nevű minta
@@ -6067,6 +6070,9 @@ breakText Glyphs Glifák
Remove Gaps Hézagok eltávolítása
@msg that makes too many pieces (at most 100000): make the dashes longer ez túl sok darabot adna (legfeljebb 100000): hosszabbítsa meg a szaggatásokat
@msg select paths, or segments, to divide jelöljön ki felosztandó útvonalakat vagy szakaszokat
# Path Precision Tools add-on: Expand Dashes into Paths.
Expand Dashes into Paths Szaggatások kibontása útvonalakká
@msg select paths with dashed strokes to expand jelöljön ki szaggatott körvonalú útvonalakat a kibontáshoz
# Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy.
Document Tools Dokumentumeszközök
Save a Timestamped Copy Másolat mentése időbélyeggel
@@ -6089,6 +6095,11 @@ breakText Glyphs Glifák
Odia Odia
Urdu Urdu
VectorCraft {release} and later (version {n}) VectorCraft {release} és újabb (verzió: {n})
# Document Tools add-on: Export All Open Documents (#4920).
Export All Open Documents as PDF Az összes megnyitott dokumentum exportálása PDF-ként
Export All Open Documents as SVG Az összes megnyitott dokumentum exportálása SVG-ként
@msgplural Exported {n} document|Exported {n} documents Exportált dokumentumok: {n}
@msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} Exportált dokumentumok: {n}; {title} nem lett kiírva: {error}
# Symbol Options › Symbol Type (dynamic symbols)
Symbol Type: Szimbólum típusa:
Dynamic Symbol Dinamikus szimbólum
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5950,6 +5950,9 @@ breakText Glyphs Glif
Gap 4 Jarak 4
Dash 5 Garis 5
Gap 5 Jarak 5
@msg {_1}: they came in as the page's art {_1}: diimpor sebagai gambar halaman
@msg You must choose a side pattern. Anda harus memilih pola sisi.
@msg invalid pattern tile choice pilihan ubin pola tidak valid
@msg no pattern named `{choice}` tidak ada pola bernama `{choice}`
@@ -5980,6 +5983,9 @@ breakText Glyphs Glif
Remove Gaps Hapus celah
@msg that makes too many pieces (at most 100000): make the dashes longer itu menghasilkan terlalu banyak potongan (paling banyak 100000): perpanjang garisnya
@msg select paths, or segments, to divide pilih jalur atau segmen yang akan dibagi
# Path Precision Tools add-on: Expand Dashes into Paths.
Expand Dashes into Paths Perluas garis putus menjadi jalur
@msg select paths with dashed strokes to expand pilih jalur dengan stroke putus-putus yang akan diperluas
# Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy.
Document Tools Alat dokumen
Save a Timestamped Copy Simpan salinan bercap waktu
@@ -6002,6 +6008,11 @@ breakText Glyphs Glif
Odia Oriya
Urdu Urdu
VectorCraft {release} and later (version {n}) VectorCraft {release} dan yang lebih baru (versi {n})
# Document Tools add-on: Export All Open Documents (#4920).
Export All Open Documents as PDF Ekspor Semua Dokumen Terbuka sebagai PDF
Export All Open Documents as SVG Ekspor Semua Dokumen Terbuka sebagai SVG
@msgplural Exported {n} document|Exported {n} documents Berhasil mengekspor {n} dokumen
@msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} Berhasil mengekspor {n} dokumen; {title} tidak ditulis: {error}
# Symbol Options › Symbol Type (dynamic symbols)
Symbol Type: Jenis simbol:
Dynamic Symbol Simbol dinamis
Can't render this file because it contains an unexpected character in line 2190 and column 5.
+11
View File
@@ -5670,6 +5670,9 @@ breakText Glyphs Glifi
Gap 4 Spazio 4
Dash 5 Tratto 5
Gap 5 Spazio 5
@msg {_1}: they came in as the page's art {_1}: sono stati importati come elementi grafici della pagina
@msg You must choose a side pattern. Devi scegliere un motivo laterale.
@msg invalid pattern tile choice scelta della tessera motivo non valida
@msg no pattern named `{choice}` nessun motivo chiamato `{choice}`
@@ -5700,6 +5703,9 @@ breakText Glyphs Glifi
Remove Gaps Rimuovi le distanze
@msg that makes too many pieces (at most 100000): make the dashes longer così si ottengono troppi pezzi (al massimo 100000): allunga i trattini
@msg select paths, or segments, to divide seleziona tracciati o segmenti da dividere
# Path Precision Tools add-on: Expand Dashes into Paths.
Expand Dashes into Paths Espandi trattini in tracciati
@msg select paths with dashed strokes to expand seleziona tracciati con traccia tratteggiata da espandere
# Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy.
Document Tools Strumenti documento
Save a Timestamped Copy Salva una copia con data e ora
@@ -5722,6 +5728,11 @@ breakText Glyphs Glifi
Odia Odia
Urdu Urdu
VectorCraft {release} and later (version {n}) VectorCraft {release} e successive (versione {n})
# Document Tools add-on: Export All Open Documents (#4920).
Export All Open Documents as PDF Esporta tutti i documenti aperti come PDF
Export All Open Documents as SVG Esporta tutti i documenti aperti come SVG
@msgplural Exported {n} document|Exported {n} documents Esportato {n} documento|Esportati {n} documenti
@msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} Esportato {n} documento; {title} non è stato scritto: {error}|Esportati {n} documenti; {title} non è stato scritto: {error}
# Symbol Options › Symbol Type (dynamic symbols)
Symbol Type: Tipo di simbolo:
Dynamic Symbol Simbolo dinamico
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5737,6 +5737,9 @@ breakText Glyphs 字形
Gap 4 間隔 4
Dash 5 線分 5
Gap 5 間隔 5
@msg {_1}: they came in as the page's art {_1}: ページのアートとして読み込みました
@msg You must choose a side pattern. 側面パターンを選択してください。
@msg invalid pattern tile choice パターンタイルの選択が無効です
@msg no pattern named `{choice}` `{choice}` というパターンはありません
@@ -5767,6 +5770,9 @@ breakText Glyphs 字形
Remove Gaps 間隔を削除
@msg that makes too many pieces (at most 100000): make the dashes longer 分割数が多すぎます(最大 100000):ダッシュを長くしてください
@msg select paths, or segments, to divide 分割するパスまたはセグメントを選択してください
# Path Precision Tools add-on: Expand Dashes into Paths.
Expand Dashes into Paths 破線をパスに拡張
@msg select paths with dashed strokes to expand 拡張する破線のパスを選択してください
# Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy.
Document Tools ドキュメントツール
Save a Timestamped Copy 日時付きのコピーを保存
@@ -5789,6 +5795,11 @@ breakText Glyphs 字形
Odia オディア語
Urdu ウルドゥー語
VectorCraft {release} and later (version {n}) VectorCraft {release} 以降 (バージョン {n})
# Document Tools add-on: Export All Open Documents (#4920).
Export All Open Documents as PDF 開いているすべてのドキュメントを PDF として書き出し
Export All Open Documents as SVG 開いているすべてのドキュメントを SVG として書き出し
@msgplural Exported {n} document|Exported {n} documents {n} 個のドキュメントを書き出しました
@msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} {n} 個のドキュメントを書き出しました; {title} は書き出されませんでした: {error}
# Symbol Options › Symbol Type (dynamic symbols)
Symbol Type: シンボルの種類:
Dynamic Symbol ダイナミックシンボル
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -6040,6 +6040,9 @@ breakText Glyphs გლიფები
Gap 4 შუალედი 4
Dash 5 ტირე 5
Gap 5 შუალედი 5
@msg {_1}: they came in as the page's art {_1}: იმპორტირებულია გვერდის გამოსახულების სახით
@msg You must choose a side pattern. აირჩიეთ გვერდითი ნიმუში.
@msg invalid pattern tile choice ნიმუშის ფილა არასწორად არის არჩეული
@msg no pattern named `{choice}` ნიმუში სახელით `{choice}` არ არსებობს
@@ -6070,6 +6073,9 @@ breakText Glyphs გლიფები
Remove Gaps ღრეჩოების მოცილება
@msg that makes too many pieces (at most 100000): make the dashes longer ეს ძალიან ბევრ ნაწილს იძლევა (მაქსიმუმ 100000): გაზარდეთ ტირეების სიგრძე
@msg select paths, or segments, to divide აირჩიეთ დასაყოფი კონტურები ან სეგმენტები
# Path Precision Tools add-on: Expand Dashes into Paths.
Expand Dashes into Paths ტირეების კონტურებად გაშლა
@msg select paths with dashed strokes to expand აირჩიეთ გასაშლელი ტირეებიანი ხაზის კონტურები
# Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy.
Document Tools დოკუმენტის ხელსაწყოები
Save a Timestamped Copy ასლის შენახვა თარიღითა და დროით
@@ -6092,6 +6098,11 @@ breakText Glyphs გლიფები
Odia ორია
Urdu ურდუ
VectorCraft {release} and later (version {n}) VectorCraft {release} და უფრო ახალი (ვერსია {n})
# Document Tools add-on: Export All Open Documents (#4920).
Export All Open Documents as PDF ყველა ღია დოკუმენტის ექსპორტი PDF-ად
Export All Open Documents as SVG ყველა ღია დოკუმენტის ექსპორტი SVG-ად
@msgplural Exported {n} document|Exported {n} documents ექსპორტირებულია დოკუმენტი ({n})
@msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} ექსპორტირებულია დოკუმენტი ({n}); {title} არ ჩაიწერა: {error}
# Symbol Options › Symbol Type (dynamic symbols)
Symbol Type: სიმბოლოს ტიპი:
Dynamic Symbol დინამიკური სიმბოლო
Can't render this file because it is too large.
+11
View File
@@ -5633,6 +5633,9 @@ breakText Glyphs 글리프
Gap 4 간격 4
Dash 5 대시 5
Gap 5 간격 5
@msg {_1}: they came in as the page's art {_1}: 페이지의 아트로 가져왔습니다
@msg You must choose a side pattern. 측면 패턴을 선택해야 합니다.
@msg invalid pattern tile choice 잘못된 패턴 타일 선택
@msg no pattern named `{choice}` `{choice}` 이름의 패턴이 없습니다
@@ -5663,6 +5666,9 @@ breakText Glyphs 글리프
Remove Gaps 간격 제거
@msg that makes too many pieces (at most 100000): make the dashes longer 조각이 너무 많아집니다(최대 100000개). 대시를 더 길게 하십시오
@msg select paths, or segments, to divide 나눌 패스나 세그먼트를 선택하십시오
# Path Precision Tools add-on: Expand Dashes into Paths.
Expand Dashes into Paths 대시를 패스로 확장
@msg select paths with dashed strokes to expand 확장할 점선 획의 패스를 선택하십시오
# Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy.
Document Tools 문서 도구
Save a Timestamped Copy 날짜와 시간이 붙은 사본 저장
@@ -5685,6 +5691,11 @@ breakText Glyphs 글리프
Odia 오리야어
Urdu 우르두어
VectorCraft {release} and later (version {n}) VectorCraft {release} 이상 (버전 {n})
# Document Tools add-on: Export All Open Documents (#4920).
Export All Open Documents as PDF 열린 모든 문서를 PDF로 내보내기
Export All Open Documents as SVG 열린 모든 문서를 SVG로 내보내기
@msgplural Exported {n} document|Exported {n} documents 문서 {n}개를 내보냈습니다
@msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} 문서 {n}개를 내보냈습니다; {title}을(를) 쓰지 못했습니다: {error}
# Symbol Options › Symbol Type (dynamic symbols)
Symbol Type: 심볼 유형:
Dynamic Symbol 동적 심볼
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5994,6 +5994,9 @@ breakText Glyphs Glyphs
Gap 4 Tussenruimte 4
Dash 5 Streepje 5
Gap 5 Tussenruimte 5
@msg {_1}: they came in as the page's art {_1}: ze zijn geïmporteerd als pagina-afbeelding
@msg You must choose a side pattern. Kies een zijpatroon.
@msg invalid pattern tile choice ongeldige keuze voor patroontegel
@msg no pattern named `{choice}` geen patroon met de naam `{choice}`
@@ -6024,6 +6027,9 @@ breakText Glyphs Glyphs
Remove Gaps Tussenruimten verwijderen
@msg that makes too many pieces (at most 100000): make the dashes longer dat geeft te veel stukken (hoogstens 100000): maak de streepjes langer
@msg select paths, or segments, to divide selecteer paden of segmenten om te verdelen
# Path Precision Tools add-on: Expand Dashes into Paths.
Expand Dashes into Paths Streepjes uitbreiden tot paden
@msg select paths with dashed strokes to expand selecteer paden met een streepjeslijn om uit te breiden
# Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy.
Document Tools Documentgereedschappen
Save a Timestamped Copy Een kopie met tijdstempel opslaan
@@ -6046,6 +6052,11 @@ breakText Glyphs Glyphs
Odia Odia
Urdu Urdu
VectorCraft {release} and later (version {n}) VectorCraft {release} en nieuwer (versie {n})
# Document Tools add-on: Export All Open Documents (#4920).
Export All Open Documents as PDF Alle geopende documenten exporteren als PDF
Export All Open Documents as SVG Alle geopende documenten exporteren als SVG
@msgplural Exported {n} document|Exported {n} documents {n} document geëxporteerd|{n} documenten geëxporteerd
@msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} {n} document geëxporteerd; {title} is niet geschreven: {error}|{n} documenten geëxporteerd; {title} is niet geschreven: {error}
# Symbol Options › Symbol Type (dynamic symbols)
Symbol Type: Symbooltype:
Dynamic Symbol Dynamisch symbool
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5906,6 +5906,9 @@ breakText Glyphs Glify
Gap 4 Odstęp 4
Dash 5 Kreska 5
Gap 5 Odstęp 5
@msg {_1}: they came in as the page's art {_1}: zaimportowano je jako grafikę strony
@msg You must choose a side pattern. Musisz wybrać wzór boczny.
@msg invalid pattern tile choice nieprawidłowy wybór kafelka wzoru
@msg no pattern named `{choice}` nie ma wzoru o nazwie `{choice}`
@@ -5936,6 +5939,9 @@ breakText Glyphs Glify
Remove Gaps Usuń odstępy
@msg that makes too many pieces (at most 100000): make the dashes longer to daje za dużo części (najwyżej 100000): wydłuż kreski
@msg select paths, or segments, to divide zaznacz ścieżki lub segmenty do podzielenia
# Path Precision Tools add-on: Expand Dashes into Paths.
Expand Dashes into Paths Rozszerz kreski na ścieżki
@msg select paths with dashed strokes to expand zaznacz ścieżki z przerywanym konturem do rozszerzenia
# Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy.
Document Tools Narzędzia dokumentu
Save a Timestamped Copy Zapisz kopię z datą i godziną
@@ -5958,6 +5964,11 @@ breakText Glyphs Glify
Odia Orija
Urdu Urdu
VectorCraft {release} and later (version {n}) VectorCraft {release} i nowsze (wersja {n})
# Document Tools add-on: Export All Open Documents (#4920).
Export All Open Documents as PDF Eksportuj wszystkie otwarte dokumenty jako PDF
Export All Open Documents as SVG Eksportuj wszystkie otwarte dokumenty jako SVG
@msgplural Exported {n} document|Exported {n} documents Wyeksportowano {n} dokument|Wyeksportowano {n} dokumenty|Wyeksportowano {n} dokumentów
@msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} Wyeksportowano {n} dokument; nie zapisano {title}: {error}|Wyeksportowano {n} dokumenty; nie zapisano {title}: {error}|Wyeksportowano {n} dokumentów; nie zapisano {title}: {error}
# Symbol Options › Symbol Type (dynamic symbols)
Symbol Type: Typ symbolu:
Dynamic Symbol Symbol dynamiczny
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5970,6 +5970,9 @@ breakText Glyphs Glifos
Gap 4 Lacuna 4
Dash 5 Traço 5
Gap 5 Lacuna 5
@msg {_1}: they came in as the page's art {_1}: foram importados como arte da página
@msg You must choose a side pattern. Você deve escolher um padrão lateral.
@msg invalid pattern tile choice escolha inválida de ladrilho de padrão
@msg no pattern named `{choice}` não há padrão chamado `{choice}`
@@ -6000,6 +6003,9 @@ breakText Glyphs Glifos
Remove Gaps Remover distâncias
@msg that makes too many pieces (at most 100000): make the dashes longer isso gera peças demais (no máximo 100000): aumente os traços
@msg select paths, or segments, to divide selecione demarcadores ou segmentos para dividir
# Path Precision Tools add-on: Expand Dashes into Paths.
Expand Dashes into Paths Expandir traços em demarcadores
@msg select paths with dashed strokes to expand selecione demarcadores com traçado tracejado para expandir
# Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy.
Document Tools Ferramentas de Documento
Save a Timestamped Copy Salvar uma Cópia com Data e Hora
@@ -6022,6 +6028,11 @@ breakText Glyphs Glifos
Odia Oriá
Urdu Urdu
VectorCraft {release} and later (version {n}) VectorCraft {release} e posteriores (versão {n})
# Document Tools add-on: Export All Open Documents (#4920).
Export All Open Documents as PDF Exportar todos os documentos abertos como PDF
Export All Open Documents as SVG Exportar todos os documentos abertos como SVG
@msgplural Exported {n} document|Exported {n} documents {n} documento exportado|{n} documentos exportados
@msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} {n} documento exportado; {title} não foi gravado: {error}|{n} documentos exportados; {title} não foi gravado: {error}
# Symbol Options › Symbol Type (dynamic symbols)
Symbol Type: Tipo de símbolo:
Dynamic Symbol Símbolo dinâmico
Can't render this file because it contains an unexpected character in line 6 and column 47.
+11
View File
@@ -6028,6 +6028,9 @@ breakText Glyphs Глифы
Gap 4 Зазор 4
Dash 5 Штрих 5
Gap 5 Зазор 5
@msg {_1}: they came in as the page's art {_1}: импортированы как графика страницы
@msg You must choose a side pattern. Выберите боковой узор.
@msg invalid pattern tile choice недопустимый выбор плитки узора
@msg no pattern named `{choice}` узор с именем `{choice}` не найден
@@ -6058,6 +6061,9 @@ breakText Glyphs Глифы
Remove Gaps Убрать зазоры
@msg that makes too many pieces (at most 100000): make the dashes longer так получится слишком много частей (не более 100000): удлините штрихи
@msg select paths, or segments, to divide выделите контуры или сегменты для разделения
# Path Precision Tools add-on: Expand Dashes into Paths.
Expand Dashes into Paths Разобрать штрихи на контуры
@msg select paths with dashed strokes to expand выделите контуры со штриховой обводкой для разбора
# Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy.
Document Tools Инструменты документа
Save a Timestamped Copy Сохранить копию с датой и временем
@@ -6080,6 +6086,11 @@ breakText Glyphs Глифы
Odia Ория
Urdu Урду
VectorCraft {release} and later (version {n}) VectorCraft {release} и новее (версия {n})
# Document Tools add-on: Export All Open Documents (#4920).
Export All Open Documents as PDF Экспортировать все открытые документы в PDF
Export All Open Documents as SVG Экспортировать все открытые документы в SVG
@msgplural Exported {n} document|Exported {n} documents Экспортирован {n} документ|Экспортировано {n} документа|Экспортировано {n} документов
@msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} Экспортирован {n} документ; {title} не записан: {error}|Экспортировано {n} документа; {title} не записан: {error}|Экспортировано {n} документов; {title} не записан: {error}
# Symbol Options › Symbol Type (dynamic symbols)
Symbol Type: Тип символа:
Dynamic Symbol Динамический символ
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -6103,6 +6103,9 @@ breakText Glyphs Glyfer
Gap 4 Mellanrum 4
Dash 5 Streck 5
Gap 5 Mellanrum 5
@msg {_1}: they came in as the page's art {_1}: de importerades som sidans bild
@msg You must choose a side pattern. Du måste välja ett sidomönster.
@msg invalid pattern tile choice ogiltigt val av mönsterplatta
@msg no pattern named `{choice}` inget mönster med namnet `{choice}`
@@ -6133,6 +6136,9 @@ breakText Glyphs Glyfer
Remove Gaps Ta bort mellanrum
@msg that makes too many pieces (at most 100000): make the dashes longer det blir för många delar (högst 100000): gör strecken längre
@msg select paths, or segments, to divide markera banor eller segment att dela upp
# Path Precision Tools add-on: Expand Dashes into Paths.
Expand Dashes into Paths Expandera streck till banor
@msg select paths with dashed strokes to expand markera banor med streckad kontur att expandera
# Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy.
Document Tools Dokumentverktyg
Save a Timestamped Copy Spara en kopia med tidsstämpel
@@ -6155,6 +6161,11 @@ breakText Glyphs Glyfer
Odia Oriya
Urdu Urdu
VectorCraft {release} and later (version {n}) VectorCraft {release} och senare (version {n})
# Document Tools add-on: Export All Open Documents (#4920).
Export All Open Documents as PDF Exportera alla öppna dokument som PDF
Export All Open Documents as SVG Exportera alla öppna dokument som SVG
@msgplural Exported {n} document|Exported {n} documents Exporterade {n} dokument|Exporterade {n} dokument
@msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} Exporterade {n} dokument; {title} skrevs inte: {error}|Exporterade {n} dokument; {title} skrevs inte: {error}
# Symbol Options › Symbol Type (dynamic symbols)
Symbol Type: Symboltyp:
Dynamic Symbol Dynamisk symbol
Can't render this file because it contains an unexpected character in line 5 and column 25.
+11
View File
@@ -6008,6 +6008,9 @@ breakText Glyphs Glifler
Gap 4 Boşluk 4
Dash 5 Çizgi 5
Gap 5 Boşluk 5
@msg {_1}: they came in as the page's art {_1}: sayfanın çizimi olarak içe aktarıldılar
@msg You must choose a side pattern. Bir yan desen seçmelisiniz.
@msg invalid pattern tile choice geçersiz desen döşemesi seçimi
@msg no pattern named `{choice}` `{choice}` adlı desen yok
@@ -6038,6 +6041,9 @@ breakText Glyphs Glifler
Remove Gaps Boşlukları Kaldır
@msg that makes too many pieces (at most 100000): make the dashes longer bu çok fazla parça oluşturur (en fazla 100000): tireleri uzatın
@msg select paths, or segments, to divide bölünecek yolları veya bölümleri seçin
# Path Precision Tools add-on: Expand Dashes into Paths.
Expand Dashes into Paths Tireleri Yollara Genişlet
@msg select paths with dashed strokes to expand genişletilecek kesikli konturlu yolları seçin
# Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy.
Document Tools Belge Araçları
Save a Timestamped Copy Zaman Damgalı Bir Kopya Kaydet
@@ -6060,6 +6066,11 @@ breakText Glyphs Glifler
Odia Oriya dili
Urdu Urduca
VectorCraft {release} and later (version {n}) VectorCraft {release} ve sonrası (sürüm {n})
# Document Tools add-on: Export All Open Documents (#4920).
Export All Open Documents as PDF Tüm Açık Belgeleri PDF Olarak Dışa Aktar
Export All Open Documents as SVG Tüm Açık Belgeleri SVG Olarak Dışa Aktar
@msgplural Exported {n} document|Exported {n} documents {n} belge dışa aktarıldı
@msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} {n} belge dışa aktarıldı; {title} yazılamadı: {error}
# Symbol Options › Symbol Type (dynamic symbols)
Symbol Type: Sembol Türü:
Dynamic Symbol Dinamik Sembol
Can't render this file because it contains an unexpected character in line 5 and column 25.
+11
View File
@@ -5650,6 +5650,9 @@ breakText Glyphs Гліфи
Gap 4 Проміжок 4
Dash 5 Штрих 5
Gap 5 Проміжок 5
@msg {_1}: they came in as the page's art {_1}: їх імпортовано як графіку сторінки
@msg You must choose a side pattern. Виберіть бічний візерунок.
@msg invalid pattern tile choice недійсний вибір плитки візерунка
@msg no pattern named `{choice}` візерунка з назвою `{choice}` немає
@@ -5680,6 +5683,9 @@ breakText Glyphs Гліфи
Remove Gaps Прибрати проміжки
@msg that makes too many pieces (at most 100000): make the dashes longer так вийде забагато частин (не більше 100000): подовжте штрихи
@msg select paths, or segments, to divide виділіть контури або сегменти для розділення
# Path Precision Tools add-on: Expand Dashes into Paths.
Expand Dashes into Paths Розібрати штрихи на контури
@msg select paths with dashed strokes to expand виділіть контури зі штриховим обведенням для розбору
# Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy.
Document Tools Інструменти документа
Save a Timestamped Copy Зберегти копію з датою і часом
@@ -5702,6 +5708,11 @@ breakText Glyphs Гліфи
Odia Одія
Urdu Урду
VectorCraft {release} and later (version {n}) VectorCraft {release} і новіші (версія {n})
# Document Tools add-on: Export All Open Documents (#4920).
Export All Open Documents as PDF Експортувати всі відкриті документи як PDF
Export All Open Documents as SVG Експортувати всі відкриті документи як SVG
@msgplural Exported {n} document|Exported {n} documents Експортовано {n} документ|Експортовано {n} документи|Експортовано {n} документів
@msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} Експортовано {n} документ; {title} не записано: {error}|Експортовано {n} документи; {title} не записано: {error}|Експортовано {n} документів; {title} не записано: {error}
# Symbol Options › Symbol Type (dynamic symbols)
Symbol Type: Тип символу:
Dynamic Symbol Динамічний символ
Can't render this file because it contains an unexpected character in line 178 and column 5.
+11
View File
@@ -5653,6 +5653,9 @@ breakText Glyphs Glyph
Gap 4 Khoảng hở 4
Dash 5 Nét 5
Gap 5 Khoảng hở 5
@msg {_1}: they came in as the page's art {_1}: đã được nhập dưới dạng hình vẽ của trang
@msg You must choose a side pattern. Bạn phải chọn mẫu bên.
@msg invalid pattern tile choice lựa chọn ô mẫu không hợp lệ
@msg no pattern named `{choice}` không có mẫu tên `{choice}`
@@ -5683,6 +5686,9 @@ breakText Glyphs Glyph
Remove Gaps Gỡ các khoảng hở
@msg that makes too many pieces (at most 100000): make the dashes longer như vậy tạo ra quá nhiều mảnh (tối đa 100000): hãy kéo dài nét
@msg select paths, or segments, to divide chọn các đường dẫn hoặc đoạn để chia
# Path Precision Tools add-on: Expand Dashes into Paths.
Expand Dashes into Paths Mở rộng nét đứt thành đường dẫn
@msg select paths with dashed strokes to expand chọn các đường dẫn có nét đứt để mở rộng
# Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy.
Document Tools Công cụ tài liệu
Save a Timestamped Copy Lưu bản sao có ngày giờ
@@ -5705,6 +5711,11 @@ breakText Glyphs Glyph
Odia Tiếng Odia
Urdu Tiếng Urdu
VectorCraft {release} and later (version {n}) VectorCraft {release} trở lên (phiên bản {n})
# Document Tools add-on: Export All Open Documents (#4920).
Export All Open Documents as PDF Xuất tất cả tài liệu đang mở dưới dạng PDF
Export All Open Documents as SVG Xuất tất cả tài liệu đang mở dưới dạng SVG
@msgplural Exported {n} document|Exported {n} documents Đã xuất {n} tài liệu
@msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} Đã xuất {n} tài liệu; không ghi được {title}: {error}
# Symbol Options › Symbol Type (dynamic symbols)
Symbol Type: Kiểu ký hiệu:
Dynamic Symbol Ký hiệu động
Can't render this file because it contains an unexpected character in line 200 and column 5.
+11
View File
@@ -5893,6 +5893,9 @@ breakText Glyphs 字形
Gap 4 间隔 4
Dash 5 虚线 5
Gap 5 间隔 5
@msg {_1}: they came in as the page's art {_1}:已作为页面图稿导入
@msg You must choose a side pattern. 必须选择侧边图案。
@msg invalid pattern tile choice 图案拼块选项无效
@msg no pattern named `{choice}` 没有名为 `{choice}` 的图案
@@ -5923,6 +5926,9 @@ breakText Glyphs 字形
Remove Gaps 移除间隙
@msg that makes too many pieces (at most 100000): make the dashes longer 这会产生过多片段(最多 100000 个):请加长短划线
@msg select paths, or segments, to divide 请选择要分割的路径或线段
# Path Precision Tools add-on: Expand Dashes into Paths.
Expand Dashes into Paths 将短划线扩展为路径
@msg select paths with dashed strokes to expand 请选择要扩展的虚线描边路径
# Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy.
Document Tools 文档工具
Save a Timestamped Copy 保存带时间戳的副本
@@ -5945,6 +5951,11 @@ breakText Glyphs 字形
Odia 奥里亚语
Urdu 乌尔都语
VectorCraft {release} and later (version {n}) VectorCraft {release} 及更高版本 (版本 {n})
# Document Tools add-on: Export All Open Documents (#4920).
Export All Open Documents as PDF 将所有打开的文档导出为 PDF
Export All Open Documents as SVG 将所有打开的文档导出为 SVG
@msgplural Exported {n} document|Exported {n} documents 已导出 {n} 个文档
@msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} 已导出 {n} 个文档; 未写入 {title}:{error}
# Symbol Options › Symbol Type (dynamic symbols)
Symbol Type: 符号类型:
Dynamic Symbol 动态符号
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5690,6 +5690,9 @@ breakText Glyphs 字符
Gap 4 間隙 4
Dash 5 虛線 5
Gap 5 間隙 5
@msg {_1}: they came in as the page's art {_1}:已作為頁面圖稿匯入
@msg You must choose a side pattern. 必須選擇側邊圖樣。
@msg invalid pattern tile choice 圖樣拼塊選項無效
@msg no pattern named `{choice}` 沒有名為 `{choice}` 的圖樣
@@ -5720,6 +5723,9 @@ breakText Glyphs 字符
Remove Gaps 移除間隙
@msg that makes too many pieces (at most 100000): make the dashes longer 這會產生過多片段(最多 100000 個):請加長短破折號
@msg select paths, or segments, to divide 請選取要分割的路徑或線段
# Path Precision Tools add-on: Expand Dashes into Paths.
Expand Dashes into Paths 將短破折號展開為路徑
@msg select paths with dashed strokes to expand 請選取要展開的虛線筆畫路徑
# Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy.
Document Tools 文件工具
Save a Timestamped Copy 儲存附時間戳記的拷貝
@@ -5742,6 +5748,11 @@ breakText Glyphs 字符
Odia 歐利亞文
Urdu 烏都文
VectorCraft {release} and later (version {n}) VectorCraft {release} 及更新版本 (版本 {n})
# Document Tools add-on: Export All Open Documents (#4920).
Export All Open Documents as PDF 將所有開啟的文件匯出為 PDF
Export All Open Documents as SVG 將所有開啟的文件匯出為 SVG
@msgplural Exported {n} document|Exported {n} documents 已匯出 {n} 份文件
@msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} 已匯出 {n} 份文件; 未寫入 {title}:{error}
# Symbol Options › Symbol Type (dynamic symbols)
Symbol Type: 符號類型:
Dynamic Symbol 動態符號
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -531,6 +531,7 @@ fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
crate::addons::pathtools::shared_edge::COMMAND => crate::addons::pathtools::shared_edge::open(app),
crate::addons::arrange::ZOOM => crate::addons::arrange::zoom(app),
crate::addons::document::SAVE_COPY => crate::addons::document::save_copy(app, p),
crate::addons::document::EXPORT_ALL => crate::addons::document::export_all(app, p),
"tool.setOption" => app.session.set_tool_option_cmd(p),
"effect.dialog" => crate::dialogs::open_effect_dialog(app, p),
"ui.recolorDialog" => crate::dialogs::recolor::open(app, p),
+142 -6
View File
@@ -1,11 +1,12 @@
//! Glyphs panel: every character the current font maps (its cmap), drawn from the font's own
//! outlines. Double-click inserts the character at the Type tool's caret (or appends it to the
//! selected text objects).
//! selected text objects). The character selected with the Type tool is highlighted in the grid.
use std::collections::HashMap;
use egui::{Color32, Sense, Stroke, StrokeKind, Ui, vec2};
use serde_json::json;
use vectorcraft_doc::NodeKind;
use vectorcraft_geom::{Affine, BezPath, PathEl};
use vectorcraft_text::FontDb;
@@ -144,6 +145,28 @@ fn glyph_texture(ctx: &egui::Context, face: &vectorcraft_text::FontFace, gid: u3
tex
}
/// The character the grid highlights: the first one of the Type tool's selected range, else the
/// one before its caret. Public behaviour: the Glyphs panel shows the selected character's glyph
/// (the overview page cited at [`CELL_STEPS`]). Walks the runs, not the characters, so it stays
/// cheap on every frame.
fn selected_char(app: &VectorcraftApp) -> Option<char> {
let (id, a, b) = super::character::text_editing(app)?;
let NodeKind::Text(t) = &app.session.active()?.doc.node(id)?.kind else { return None };
let (at, range) = (a.min(b), a != b);
let mut start = 0;
for run in &t.runs {
let end = start + run.text.len();
if range && (start..end).contains(&at) {
return run.text.get(at - start..)?.chars().next();
}
if !range && at > start && at <= end {
return run.text.get(..at - start)?.chars().next_back();
}
start = end;
}
None
}
/// Insert `ch` at the Type tool's caret, else append it to the selected text.
fn insert(app: &mut VectorcraftApp, ctx: &egui::Context, ch: char) {
let s = ch.to_string();
@@ -193,17 +216,35 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let px = (cell * ui.ctx().pixels_per_point()).round().clamp(8.0, 256.0) as u32;
let list_h = (ui.available_height() - 64.0).max(cell * 3.0);
let hover_id = egui::Id::unique("gl-hover");
egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, false]).show_rows(ui, cell, rows, |ui, range| {
for r in range {
let selected = selected_char(app);
// The selected character and the rows shown last frame: the grid scrolls to the selected glyph
// only when the selection moves to another character, so it still scrolls freely meanwhile.
let follow_id = egui::Id::unique("gl-follow");
let (followed, shown): (Option<char>, std::ops::Range<usize>) = ui.ctx().data(|d| d.get_temp(follow_id)).unwrap_or_default();
let mut area = egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, false]);
if selected != followed
&& let Some(c) = selected
&& let Ok(i) = chars.binary_search_by_key(&c, |x| x.0)
&& !shown.contains(&(i / cols))
{
let row_h = cell + ui.spacing().item_spacing.y;
area = area.vertical_scroll_offset(((i / cols) as f32 + 0.5) * row_h - list_h * 0.5);
}
let shown = area.show_rows(ui, cell, rows, |ui, range| {
for r in range.clone() {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
for &(c, gid) in chars.iter().skip(r * cols).take(cols) {
let (rect, resp) = ui.allocate_exact_size(vec2(cell, cell), Sense::click());
let chosen = selected == Some(c);
if resp.hovered() {
ui.painter().rect_filled(rect, 2, t.hover);
ui.ctx().data_mut(|d| d.insert_temp(hover_id, (c, gid)));
} else if chosen {
ui.painter().rect_filled(rect, 2, t.row_selected);
}
ui.painter().rect_stroke(rect.shrink(0.5), 0, egui::Stroke::new(0.5, t.border), egui::StrokeKind::Inside);
let edge = if chosen { Stroke::new(1.5, t.accent) } else { Stroke::new(0.5, t.border) };
ui.painter().rect_stroke(rect.shrink(0.5), 0, edge, StrokeKind::Inside);
let tex = glyph_texture(ui.ctx(), &face, gid, px);
ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), t.text_strong);
let resp = resp.on_hover_text(format!("U+{:04X} {c} (glyph {gid})\n{}", c as u32, tl!("Double-click to insert")));
@@ -213,7 +254,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
});
}
range
});
ui.ctx().data_mut(|d| d.insert_temp(follow_id, (selected, shown.inner)));
ui.add_space(4.0);
let info = ui.ctx().data(|d| d.get_temp::<(char, u32)>(hover_id));
ui.label(
@@ -295,14 +338,46 @@ mod tests {
use super::*;
fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<egui::Event>) -> (Vec<(String, Pos2)>, Vec<f32>) {
fn shapes(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<egui::Event>) -> Vec<Shape> {
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 420.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
out.textures_delta.clear();
let shapes: Vec<Shape> = out.shapes.into_iter().map(|c| c.shape).collect();
out.shapes.into_iter().map(|c| c.shape).collect()
}
fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<egui::Event>) -> (Vec<(String, Pos2)>, Vec<f32>) {
let shapes = shapes(ctx, app, events);
(text_anchors(&shapes), square_sides(&shapes))
}
/// The Type tool editing `text` in a new document.
fn editing(text: &str) -> (VectorcraftApp, serde_json::Value) {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 200})).unwrap();
app.session.select_tool("type", Default::default()).unwrap();
let id = app.run("text.create", json!({"x": 20, "y": 50, "text": text})).unwrap()["id"].clone();
app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap();
(app, id)
}
fn select(app: &mut VectorcraftApp, start: usize, end: usize) {
app.session.set_tool_option("select", &json!({"start": start, "end": end}));
}
/// Centres of the highlighted (selected-character) cells.
fn highlighted(shapes: &[Shape], t: &Tokens) -> Vec<Pos2> {
fn walk(s: &Shape, fill: Color32, out: &mut Vec<Pos2>) {
match s {
Shape::Rect(r) if r.fill == fill => out.push(r.rect.center()),
Shape::Vec(v) => v.iter().for_each(|s| walk(s, fill, out)),
_ => {}
}
}
let mut out = vec![];
shapes.iter().for_each(|s| walk(s, t.row_selected, &mut out));
out
}
fn text_anchors(shapes: &[Shape]) -> Vec<(String, Pos2)> {
fn walk(s: &Shape, out: &mut Vec<(String, Pos2)>) {
match s {
@@ -350,6 +425,67 @@ mod tests {
frame(ctx, app, vec![button(false)]);
}
#[test]
fn the_selected_character_is_the_range_start_or_the_one_before_the_caret() {
let (mut app, id) = editing("aHé");
select(&mut app, 1, 2);
assert_eq!(selected_char(&app), Some('H'));
select(&mut app, 2, 2);
assert_eq!(selected_char(&app), Some('H'));
select(&mut app, 4, 1);
assert_eq!(selected_char(&app), Some('H'), "a backward range starts at its lower end");
select(&mut app, 0, 0);
assert_eq!(selected_char(&app), None, "nothing before the caret");
// Across runs: a style on `H` alone splits the text into three runs.
app.run("text.setRangeStyle", json!({"id": id, "start": 1, "end": 2, "kerning": 50})).unwrap();
select(&mut app, 1, 1);
assert_eq!(selected_char(&app), Some('a'));
select(&mut app, 2, 2);
assert_eq!(selected_char(&app), Some('H'));
select(&mut app, 2, 4);
assert_eq!(selected_char(&app), Some('é'));
app.session.select_tool("select", Default::default()).unwrap();
assert_eq!(selected_char(&app), None, "only the Type tool's selection counts");
}
#[test]
fn the_selected_glyph_is_highlighted_and_scrolled_into_view() {
let (mut app, _) = editing("A");
let ctx = egui::Context::default();
let t = Tokens::get(&ctx);
let (texts, _) = frame(&ctx, &mut app, vec![]);
if texts.iter().any(|(t, _)| t.contains("No fonts are available")) {
eprintln!("glyphs highlight: no fonts installed, grid not drawn");
return;
}
let (family, style) = super::super::character::text_style(&app).map(|(s, _)| (s.font_family, s.font_style)).unwrap();
let face = FontDb::global().face(&family, &style).unwrap();
let chars: Vec<char> = face.chars().into_iter().map(|(c, _)| c).filter(|c| !c.is_control() && !c.is_whitespace()).collect();
let (Some(&first), Some(&last)) = (chars.first(), chars.last()) else { return };
if chars.len() < 200 {
eprintln!("glyphs highlight: font too small to need scrolling");
return;
}
let (mut app, _) = editing(&format!("{first}{last}"));
let end = first.len_utf8() + last.len_utf8();
select(&mut app, end, end);
let ctx = egui::Context::default();
// The caret sits after the last glyph of the font, far below the first screen of the grid.
let lit = highlighted(&shapes(&ctx, &mut app, vec![]), &t);
assert_eq!(lit.len(), 1, "the grid scrolled to the selected glyph and highlighted it: {lit:?}");
let lit = highlighted(&shapes(&ctx, &mut app, vec![]), &t);
assert_eq!(lit.len(), 1, "it stays highlighted");
// Highlighting the first character scrolls back up to it.
select(&mut app, 0, 1);
shapes(&ctx, &mut app, vec![]);
let top = highlighted(&shapes(&ctx, &mut app, vec![]), &t);
assert_eq!(top.len(), 1, "{top:?}");
assert!(top[0].y < lit[0].y, "the first glyph sits at the top of the grid: {top:?} vs {lit:?}");
// The caret at the start selects nothing.
select(&mut app, 0, 0);
assert!(highlighted(&shapes(&ctx, &mut app, vec![]), &t).is_empty());
}
#[test]
fn zoom_stops_at_the_ends() {
assert_eq!(nudge_cell(CELL_DEFAULT, 1), CELL_DEFAULT + 1);
File diff suppressed because one or more lines are too long
+23
View File
@@ -6082,6 +6082,17 @@ Commands:
last piece of each run is what is left. The pieces are selected; more than 100,000 is refused. → `{ids, dashes}`.
Divide into Dashes… opens a dialog (`ui.menuDialog {command}`, kind `divideDashes`) whose fields are these
parameters, with Preview.
- `addon.pathtools.expandDashes {ids?}` turns the dashed stroke of each path and compound path under `ids` (default:
the selection) into separate open paths, one per dash, as one undo step (Object › Plug-ins › Path Precision Tools ›
Expand Dashes into Paths). The dashes are laid out exactly as the stroke draws them: its dash pattern (an odd one
repeats with dashes and gaps swapped), its dash offset and Align Dashes to Corners and Path Ends, restarting on each
subpath and running on through the start of a closed one; a dash running all the way round a closed path stays
closed. Each dash keeps the path's appearance with the top stroke's dashes off, so the gaps become real gaps; with a
round or projecting cap a dash of no length (a dot) becomes a path on its point, which draws the cap (under a butt
cap it paints nothing, so it makes nothing). The stroke's arrowheads go, the line is first trimmed under them as
drawn, and the path's fills stay on it, below its dashes (a path without fills goes). A compound path's dashes are
plain paths with its appearance. Paths without a dashed stroke stay as they are, and it is an error if none has one.
The dashes are selected; more than 100,000 (or a pattern too fine to draw) is refused. → `{ids, dashes}`.
- `addon.pathtools.cornerSet {ids?, anchors?, items?, radius, kind?}` cuts live corners, the ones Direct Selection's
corner widgets and the Corners dialog edit, as one undo step. `ids` (default: the selection) are the paths and
`anchors` the anchor indices of each path's outline with no corners cut; without `anchors`, a path's direct-selected
@@ -6401,6 +6412,18 @@ command is off where there is no clock (the web). → `{path, format, bytes, war
{"name":"run_command","arguments":{"command":"addon.document.saveTimestampedCopy","params":{"folder":"/Users/me/Versions","format":"pdf"}}}
```
`addon.document.exportAllOpen {folder, format?}` writes a copy of every open document, in tab order, to `folder`,
each named after its document (`<name>.<ext>`, with `-2`, `-3`… added when that name is taken there or by another
copy, so no file is replaced). `format` is a save format (`pdf` by default; `svg`, `svgz`, `vectorcraft`, `ai`,
`eps`). Each copy is written as `file.saveCopy` writes it, inside the automation roots in force: every document keeps
its path, title and modified state, no undo step is made, and the active document stays active. A document that
can't be written is listed in `failed` and the others are still written (#4920). The desktop app's Export All Open
Documents as PDF and as SVG ask for the folder. → `{files: [{title, path, warnings}], failed: [{title, error}], format}`
```json
{"name":"run_command","arguments":{"command":"addon.document.exportAllOpen","params":{"folder":"/Users/me/Out","format":"svg"}}}
```
`addon.document.symbolsFromLayers {layers?, replace?}` makes one symbol of each top-level layer's art, as `symbol.new`
makes one (registration in the centre), named after the layer with a number added when a symbol has that name, as
one undo step. `layers` lists layer ids (top-level layers only; anything else is refused); by default it takes the
+15 -2
View File
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Arrange Tools add-on: Sort Objects, #4917; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Path Precision Tools add-on: Expand Dashes into Paths, #4922; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
## Feature submission evidence
@@ -127,7 +127,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (four generated libraries, User Defined, Other Library…, Save Brushes); remaining: Art brush Overlap, Pattern brush auto corners, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) |
| Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated |
| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) |
| Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208) |
| Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests) |
| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, on-canvas Direct Selection of parts inside dynamic symbol instances and their `.ai` round trip, more built-in symbol libraries, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guide settings (stored, not drawn) and Symbol Type (dynamic symbols keep per-instance appearance linked, #4938) are done | 4–8 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged |
| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged |
@@ -706,6 +706,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
While Alt is held, the Scissors tool works as the Add Anchor Point tool ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)): a click on a segment adds an anchor point without cutting (`path.insertAnchor`), and the pointer shows the Add Anchor Point cursor. When Alt is let go, a click cuts again.
Alt-drag with the Knife cuts in a straight line, and Shift+Alt-drag cuts at 45° or 90°, as [Cut objects using the Knife and Scissors tools](https://web.archive.org/web/20260926095638/https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/cut-objects.html) and [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) describe. Alt counts only when held at the press; the dashed line runs from the press point to the pointer, stays straight after Alt is let go, and release sends `path.knife` with those two points. Alt pressed after the press leaves the cut freehand. Shift+Alt keeps the endpoint at 45° steps from the horizontal and vertical, whatever the Constrain Angle.
With Smart Guides on, a straight Knife cut snaps to anchor points and paths: [Cut objects using the Knife and Scissors tools](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/cut-objects.html) describes the Knife snapping to anchor points and paths while Smart Guides are on. Its ends snap as the ends of the Pencil's straight line do, to anchor points and path segments of art that is shown: the press, and the release of an Alt-drag. Shift+Alt steps 45° from the snapped press, and its release stays on that step. With Smart Guides off, or with Snap to Pixel or Snap to Grid on, the cut is not snapped. The dashed line and `path.knife` use the snapped points.
Eraser Tool Options has Angle and Roundness besides Size, as [Erase paths using the Eraser tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/erase-paths-using-eraser-tool.html) describes: the Eraser's tip is an ellipse Size across, its long axis at Angle and its short axis Roundness percent of the long one, and a drag with a tip that is not round shows the outline of the area it erases (`path.eraseRegion` `angle` and `roundness`).
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
Align, Distribute, Distribute Spacing, Reflect and Shear return an error for any `horizontal`, `vertical`, `to`, `axis`, `bounds` or `spacing` value they don't take, listing the choices, and Align returns one when a call gives neither `horizontal` nor `vertical`; nothing moves and no undo step is recorded. Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing).
@@ -1066,6 +1067,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
A PDF page whose `UserUnit` (PDF 1.6) is above 1 opens at its size in points, its numbers times the `UserUnit` (one above 75,000 counts as 75,000), and `document.pdfInfo` gives its size and boxes in points too. A `.ai` whose editing data has its artboards in the units of such a page opens from that data at the page's size, with its layers, and when the layers can't be read its pages still take the artboard layout of the data, scaled the same way (part of #1482).
When the editing data of a `.ai` or EPS is for a page of another size than the file's, the warning that its layers weren't read gives both sizes in points, and the scale of the editing data's page to the file's page (1:10 for a tenth of its size) when their sides are in proportion (#2034; `crates/eps/src/import/tests_native.rs`).
A `.ai`, or a PDF saved with Illustrator's editing data, whose editing data can't be decompressed opens from its PDF part with a warning that its layers weren't read and why, and an export or Save As to its own path asks first (`acknowledgeLoss`), as for a file whose layers the reader can't read (`crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`).
A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`).
PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); in PDF 1.3 to 1.5 files and PDF/X files such a page is written in points.
Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document or at its path is read as the document opens). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392).
A `.ai` saved without PDF compatibility (its PDF part only a placeholder page) is placed and pasted from its editing data, as it opens: File › Place places the art of the layers that show on the first artboard (`file.place`'s `page` places another one), and Paste of it as PDF data (`clipboard.importPdf`) takes every object. `document.open` with `pages` naming every page of such a file opens it the same way (`crates/engine/src/cmd/fileio/tests_aiimport.rs`).
@@ -1431,6 +1433,10 @@ A VectorCraft addition, not a feature of the reference app, so no area score mov
A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Path Precision Tools › Divide into Dashes… cuts the selected paths into separate open paths along the curve (arc length, not straight distance): each `dash` long (0.01 to 1000000 pt, 10 by default) with `gap` between them (0 by default: the pieces touch); the gap pieces are left out unless Remove Gaps is off. With direct-selected segments (or anchors) only those runs are divided, the pattern running on across segments that follow one another, and the rest of such a path stays as pieces of its own. Fit to Corners and Ends gives every run between corners and ends a whole number of dashes, all stretched or squeezed alike so it starts and ends on a dash (a closed path without corners holds whole periods); otherwise the lengths are exact and each run's last piece is what is left. Every piece keeps the path's appearance and is selected; one undo step; at most 100000 pieces (more is refused). The dialog takes lengths in any unit and previews on the canvas. Tests: `dashes::tests` in `vectorcraft-pathops` (exact and fitted lengths, curves measured along them, picked segments on open and closed paths, the piece cap and bad patterns), `divide_into_dashes_*` in `cmd::addons::pathtools::tests` in `vectorcraft-engine` (gaps removed or kept, fitted lengths, the stroke kept, one undo step, selected segments only, bad params and too many pieces refused), `divide_into_dashes_opens_its_dialog_previews_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Path Precision Tools add-on.
## Path Precision Tools add-on: Expand Dashes into Paths (2026-11-01)
A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Path Precision Tools › Expand Dashes into Paths turns the dashed stroke of each selected path and compound path into separate open paths, one per dash, for cutters and plotters that need the centre lines rather than Outline Stroke's filled outlines. The dashes are laid out by the renderer's own dash code, so they match what the canvas draws: the dash pattern, its offset and Align Dashes to Corners and Path Ends, a dash running on through the start of a closed path, a dash all the way round staying closed, and under a round or projecting cap each dot (a dash of no length) as a path on its point. Each dash keeps the path's appearance with the top stroke's dashes and arrowheads off; the path's fills stay on it, below its dashes; a compound path's dashes are plain paths. One undo step; the dashes are selected; at most 100000 (more, or a pattern too fine to draw, is refused). Tests: `expand_dashes_*` in `cmd::addons::pathtools::tests` in `vectorcraft-engine` (the stroke kept with dashes off, the offset, one undo step, the fill kept below, dashes centred on corners when aligned, dots under round and butt caps, compound paths, refusals and the piece cap), `the_plugins_submenu_selects_the_tools_and_opens_the_anchor_inspector` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Path Precision Tools add-on.
## Document Tools add-on: Save a Timestamped Copy (2026-11-01)
A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Document Tools › Save a Timestamped Copy writes a copy of the document named `<name>_YYYY-MM-DD_HH-MM.<ext>` (the date and time in UTC, through chrono; `-2`, `-3`… added when that name is taken) next to the document, or into a folder picked (the folder picker) when it was never saved, in the document's own format or another save format. It writes through File › Save a Copy's save, so the document keeps its path, title and modified state, no undo step is made, and the write stays inside the automation roots in force. It is off on the web, which has no clock. Tests: `cmd::addons::document::tests` in `vectorcraft-engine` (the name and place, numbered names, the document unchanged and its content in the copy, an unsaved document needing a folder, other formats, bad params refused, automation roots), `addons::document::tests` in `vectorcraft-ui-egui` (the submenu, beside the document, the folder picked or cancelled) and `command_sweep`. Commands: `docs/mcp.md` › Document Tools add-on.
@@ -1443,10 +1449,17 @@ A VectorCraft addition, not a feature of the reference app, so no area score mov
Symbols area (#4938), from the public symbol documentation ([create and place symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html), [edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html)). Symbol Options has Symbol Type (Dynamic Symbol / Static Symbol; the dialog's default is Dynamic, `symbol.new`'s `type` defaults to static so scripts behave as before). An instance of a dynamic symbol keeps its own fill, stroke, opacity and blend for any object of the symbol's art, named by a child-index path (`symbol.instance.setAppearance`), and stays an instance: the canvas, SVG, PDF, EPS, legacy `.ai`, EMF/WMF and DXF writers, effects and Expand draw the art with those changes. Redefining the symbol reshapes every instance and keeps each change whose object the new art still has; Break Link bakes the changes into the art; Edit Symbol expands the symbol's own art; Reset (`symbol.instance.reset`: the Properties panel's Quick Actions and the Symbols panel menu) and making the symbol static drop them. The type is stored on the symbol and the changes on the instance in the native format, both left out when unset, so documents without them save byte for byte as before. Remaining: Direct Selection of an object inside a dynamic instance on the canvas (with the Fill/Stroke controls then setting its change), and reading and writing the type and changes in `.ai` files. Evidence: `crates/engine/src/tests/dynamic_symbols.rs`, `crates/doc/src/symbol_overrides.rs` tests. Score unchanged at 87% (estimated); hours 5–9 → 4–8.
## Document Tools add-on: Export All Open Documents (2026-11-01)
A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Document Tools › Export All Open Documents as PDF (or as SVG) asks for a folder and writes a copy of every open document there, each named after its document with `-2`, `-3`… added when a name is taken, so no file is replaced (`addon.document.exportAllOpen {folder, format?}`, #4920). Each copy is written as Save a Copy writes it: the documents keep their paths, titles and modified state, and the active one stays active. A document that can't be written is named in the status bar and the others are still written. Tests: `addons::document::tests::export_all_open_documents_writes_each_to_the_folder_picked` (ui-egui), `cmd::addons::document::tests::export_all_open_documents_writes_each_and_reports_failures` (engine).
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-11-01 | minor | Document Tools add-on (a VectorCraft addition): Export All Open Documents as PDF or SVG (#4920); evidence added; scores unchanged. |
| 2026-11-01 | minor | Path Precision Tools add-on (a VectorCraft addition): Expand Dashes into Paths (#4922); evidence added; scores unchanged. |
| 2026-11-01 | minor | Type core: the Glyphs panel highlights the character selected with the Type tool and scrolls it into view (#4908); evidence added; scores unchanged. |
| 2026-11-01 | minor | Arrange Tools add-on (a VectorCraft addition): Randomize Opacity (#4925); evidence added; scores unchanged. |
| 2026-11-01 | minor | Arrange Tools add-on (a VectorCraft addition): Sort Objects (#4917); evidence added; scores unchanged. |
| 2026-11-01 | minor | Symbols panel: Select All Unused (`symbol.unused`) shows the symbols nothing places selected, and Delete Symbol deletes them all, asked once, in one undo step (`symbol.delete {names}`, #4918); scores and hours unchanged |
+3 -2
View File
@@ -1,6 +1,6 @@
# Type parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Serbian in Cyrillic and Latin and Croatian among the languages, with their quote marks; previously minor: Area Type Options › Text Flow by rows or by columns, #4978; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Glyphs panel: the character selected with the Type tool is highlighted and scrolled into view, #4908; previously minor: Serbian in Cyrillic and Latin and Croatian among the languages, with their quote marks; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in
[target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the
@@ -28,7 +28,7 @@ against Illustrator's public documentation; **estimated** unless noted.
| Character and Paragraph panels, styles with overrides | done | Character-style Auto-leading resets, partial definitions, local leading overrides, override markers, Undo/Redo and native serialization have regression coverage; differing paragraph alignments leave the shared alignment buttons unselected (headless regressions, full integration CI and visual review pass); mixed-value display for other paragraph fields remains missing |
| Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. |
| Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. |
| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607) |
| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges, and Alternates for Current Selection is not there yet |
| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux) |
| Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG |
| Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending |
@@ -73,6 +73,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha
| Date | Change | Summary |
|---|---|---|
| 2026-11-01 | minor | Glyphs panel (#4908): the character selected with the Type tool (a range's first, else the one before the caret) is highlighted in the grid and scrolled into view when the selection moves to another character; Show › Alternates for Current Selection not yet; scores unchanged |
| 2026-11-01 | minor | Serbian: Cyrillic (`sr-Cyrl`), Serbian: Latin (`sr-Latn`) and Croatian (`hr`) in the language list, with their quote marks (Serbian „…” and ’…’ per the Pravopis srpskoga jezika, Matica srpska 2010, ISBN 978-86-7946-079-0, т. 168, and [CLDR `sr`](https://github.com/unicode-org/cldr/blob/main/common/main/sr.xml); Croatian „…“ and ‚…‘ per [CLDR `hr`](https://github.com/unicode-org/cldr/blob/main/common/main/hr.xml)); Serbian text gets the fonts' Serbian forms (italic б, г, д, п, т); scores unchanged |
| 2026-11-01 | minor | Area Type Options › Text Flow (#4978): By Rows / By Columns radio buttons and `text.areaOptions {flow}`; area type with several rows and columns fills them across each row first or down each column first (the default, so existing documents are unchanged; saved only when by rows); scores unchanged |
| 2026-11-01 | minor | Digits (#4942): the native digits of Devanagari, Bengali, Gurmukhi, Gujarati, Odia, Tamil, Telugu, Kannada and Malayalam in the Character panel menu › Digits and `{digits}`; scores unchanged |
+5 -1
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Arrange Tools add-on: Sort Objects, #4917) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Path Precision Tools add-on: Expand Dashes into Paths, #4922) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -337,10 +337,12 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po
| Arrange Tools add-on (VectorCraft addition): Object › Plug-ins › Arrange Tools › Randomize Opacity… gives each selected object its own opacity at random between Minimum and Maximum (%), with a Seed that repeats it and Preview, one undo step | done | #4925; `addon.arrange.randomizeOpacity`, `crates/engine/src/cmd/addons/arrange.rs`, `crates/ui-egui/src/addons/arrange.rs` and their tests; not a feature of the reference app, so it is not scored |
| Arrange Tools add-on (VectorCraft addition): Object › Plug-ins › Arrange Tools › Sort Objects… sorts the selected objects by Sort By (width, height, area, X or Y position, opacity, stacking order) in an Order (Small to Large, Large to Small, Random) and gives them Apply To in that order, trading the values or with Distribute Evenly; Scale Strokes & Effects; Preview; one undo step | done | #4917; `addon.arrange.sort`, `crates/engine/src/cmd/addons/arrange.rs`, `crates/ui-egui/src/addons/arrange.rs` and their tests; not a feature of the reference app, so it is not scored |
| Document Tools add-on (VectorCraft addition): Object › Plug-ins › Document Tools › Save a Timestamped Copy writes `<name>_YYYY-MM-DD_HH-MM.<ext>` next to the document (a folder picked for one never saved), which keeps its path, title and modified state; off on the web | done | #4926; `addon.document.saveTimestampedCopy`, `crates/engine/src/cmd/addons/document.rs`, `crates/ui-egui/src/addons/document.rs` and their tests; not a feature of the reference app, so it is not scored |
| Document Tools add-on (VectorCraft addition): Export All Open Documents as PDF / as SVG (#4920) | done | asks for a folder, writes a copy of every open document named after it (taken names numbered, nothing replaced), documents and active tab unchanged; failures named in the status bar |
| Document Tools add-on (VectorCraft addition): Object › Plug-ins › Document Tools › New Symbols from Layers makes a symbol of each layer's art named after the layer (the highlighted layers when two or more are, else all); Convert Layers to Symbols also replaces each layer's art with an instance in place; empty layers skipped, one undo step | done | #4927; `addon.document.symbolsFromLayers`, `crates/engine/src/cmd/addons/document.rs`, `crates/ui-egui/src/addons/document.rs` and their tests; not a feature of the reference app, so it is not scored |
| Smart Pen add-on (VectorCraft addition): Object › Plug-ins › Smart Pen selects the Smart Pen Tool, which the All Tools drawer lists under its own heading but neither toolbar shows as shipped (no shortcut), and opens the Smart Pen panel (next segment straight or curved; Remove Points Keep Shape, Make Smooth, Make Corner, Retract Handles, Close or Join). The tool draws (click: straight or curved segment, a curve after a straight one tangent; drag: smooth anchor, Alt breaks the handle, Shift 45°, Space moves it; Tab switches a segment; rubber band; close, join, continue) and edits without switching tools (click selects a point, drag moves it, Alt-click removes it keeping the shape, Shift+Alt-click deletes it, double-click toggles smooth/corner; handle drags with Shift and Alt; segment drags reshape, Alt-click adds a point) | done | `addon.pen.*`, `crates/tools/src/addons/smart_pen.rs`, `crates/ui-egui/src/addons/pen.rs` and their tests; not a feature of the reference app, so it is not scored |
| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its seven tools (Anchor Eraser Brush, Extend Path, Measure & Inspect, Parametric Shape, Corner Tool, Slide Anchor Along Path, Shared Edge Merge Tool), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and the Parametric Shape panel (kind, count, W/H, rotation, the kind's own values and corner radius of the selected parametric shape, or of the tool's next one), and runs Add Points at / Split at Crossings and Convert to Parametric Shape; the Parametric Shape Tool draws ten re-editable kinds from the centre (↑/↓ and ←/→ change their numbers while dragging) and edits a selected one with handles; the Corner Tool cuts editable corners on any path (hover shows the anchor, a click uses its radius and kind, a drag sizes the corner, Shift whole values; a corner's marker selects it and loads its values, drags to resize, to 0 or double-clicked removes it; never past what fits; Alt-marquee cuts every anchor inside) with a Corners section in the Anchor Inspector (radius, kind, selected count, Apply to Selected Anchors); Slide Anchor Along Path drags an anchor along its curve with the segments beside it refitted, Shift snapping where the tangent is a multiple of 45°; the Shared Edge Merge Tool shows, over two paths touching at two coincident anchors, the two stretches and the edge merging them, a click merges them (half way; Shift the upper path, Alt the lower), and near anchors meet or an anchor one path lacks is added to match (Shift+Alt asks for this inside a stretch), with a Shared Edges section in the Anchor Inspector (brush diameter, coincidence 0 to 3 pt, Keep Shared Points Together, which takes coincident anchors along with the add-on's own anchor moves); a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs`, `crates/ui-egui/src/addons/pathtools/` and their tests; not a feature of the reference app, so it is not scored |
| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools › Divide into Dashes… (Dash and Gap in any unit, Fit to Corners and Ends, Remove Gaps; Preview) cuts the selected paths, or only their direct-selected segments, into open pieces along the curve, keeping their appearance, one undo step | done | #4950; `addon.pathtools.divideDashes`, `crates/pathops/src/addons/pathtools/dashes.rs`, `crates/ui-egui/src/addons/pathtools/dashes.rs` and their tests; not a feature of the reference app, so it is not scored |
| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools › Expand Dashes into Paths turns each selected path's dashed stroke into separate open paths, one per dash, laid out as the stroke draws them (pattern, offset, Align Dashes to Corners and Path Ends), each keeping the stroke with the dashes off; one undo step | done | #4922; `addon.pathtools.expandDashes` (in `crates/engine/src/cmd/addons/pathtools.rs`, using the renderer's dash layout) and its tests; not a feature of the reference app, so it is not scored |
| Path Precision Tools add-on (VectorCraft addition): Effect › Path Effects lists its live effects (Dashes to Geometry, Add Anchors, Extend Path, Close Path, Smooth Anchors, Retract Handles, Reverse Direction, Simplify Anchors, Split at Anchors), each with the effect dialog's live Preview except Reverse Direction, which has no options, and listed in the Appearance panel like any effect | done | `addon.path.*`, `crates/effects/src/addons/path.rs`, `crates/ui-egui/src/addons/pathtools.rs` and their tests; not a feature of the reference app, so it is not scored |
| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its four tools (Anchor Eraser Brush, Extend Path, Measure & Inspect, Parametric Shape), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and the Parametric Shape panel (kind, count, W/H, rotation, the kind's own values and corner radius of the selected parametric shape, or of the tool's next one), and runs Add Points at / Split at Crossings and Convert to Parametric Shape; the Parametric Shape Tool draws ten re-editable kinds from the centre (↑/↓ and ←/→ change their numbers while dragging) and edits a selected one with handles; a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs`, `crates/ui-egui/src/addons/pathtools/shape.rs` and their tests; not a feature of the reference app, so it is not scored |
| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its six tools (Anchor Eraser Brush, Extend Path, Measure & Inspect, Parametric Shape, Corner Tool, Slide Anchor Along Path), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and the Parametric Shape panel (kind, count, W/H, rotation, the kind's own values and corner radius of the selected parametric shape, or of the tool's next one), and runs Add Points at / Split at Crossings and Convert to Parametric Shape; the Parametric Shape Tool draws ten re-editable kinds from the centre (↑/↓ and ←/→ change their numbers while dragging) and edits a selected one with handles; the Corner Tool cuts editable corners on any path (hover shows the anchor, a click uses its radius and kind, a drag sizes the corner, Shift whole values; a corner's marker selects it and loads its values, drags to resize, to 0 or double-clicked removes it; never past what fits; Alt-marquee cuts every anchor inside) with a Corners section in the Anchor Inspector (radius, kind, selected count, Apply to Selected Anchors); Slide Anchor Along Path drags an anchor along its curve with the segments beside it refitted, Shift snapping where the tangent is a multiple of 45°; a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs`, `crates/ui-egui/src/addons/pathtools/` and their tests; not a feature of the reference app, so it is not scored |
@@ -391,6 +393,8 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-11-01 | minor | Document Tools add-on (a VectorCraft addition): Export All Open Documents as PDF or SVG (#4920); scores and estimates unchanged |
| 2026-11-01 | minor | Path Precision Tools add-on (a VectorCraft addition): Expand Dashes into Paths (#4922); scores and estimates unchanged |
| 2026-11-01 | minor | Arrange Tools add-on (a VectorCraft addition): Randomize Opacity (#4925); scores and estimates unchanged |
| 2026-11-01 | minor | Arrange Tools add-on (a VectorCraft addition): Sort Objects (#4917); scores and estimates unchanged |
| 2026-11-01 | minor | Blob Brush and Eraser: the tip's outline (size, angle, roundness, at the zoom) follows the pointer before and while painting, around a small centre dot, updating at once with `[` / `]` and Tool Options; precise cursors (Caps Lock) show only the crosshair (#4977); scores and estimates unchanged (estimated) |