diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d1677e538..07b3c6b7f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,6 +63,7 @@ jobs: needs: git-sources permissions: contents: write + packages: write # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/release.yml@main with: diff --git a/ROADMAP.md b/ROADMAP.md index cbce1fbfa..c85be5aef 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. | diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 528d56033..bde5fc809 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -78,6 +78,14 @@ const PERCEPTUAL_TOLERANCE: f32 = 0.4 / 255.0; /// Most times each half of a Perceptual gradient's segment (either side of its midpoint) is /// halved to keep within [`PERCEPTUAL_TOLERANCE`]. const PERCEPTUAL_DEPTH: u32 = 10; +/// How many times [`Gradient::ink_tints`] halves the range of tints it searches: to within +/// 1/65536 of a tint, finer than the 1/255 steps PDF writes. +const TINT_STEPS: u32 = 16; +/// The least OKLab lightness by which an ink at 100% must differ from paper white for +/// [`Gradient::ink_tints`] to tell its tints apart by lightness. +const MIN_INK_LIGHTNESS_SPAN: f64 = 1.0 / 255.0; +/// Most stops on a gradient's curve that [`Gradient::ink_tints`] finds tints for. +const MAX_INK_TINTS: usize = 4096; /// The mix positions (0..1, after `v0`, before `v1`) between which display RGB mixed linearly /// stays within [`PERCEPTUAL_TOLERANCE`] of the perceptual mix of `a` and `b`: the span halved @@ -284,6 +292,43 @@ impl Gradient { std::iter::once((s.offset, s.color, s.opacity, None)).chain(classic).chain(perceptual.into_iter().flatten()) }) } + /// A gradient of tints of one ink `ink` (its color at 100%; paper white is 0% of it) as + /// (offset, tint, opacity) at each of [`Self::expanded_stops`]: the tint (0..1) whose OKLab + /// lightness is the lightness of the color there, found to [`TINT_STEPS`] halvings. Mixed + /// linearly between them, the tints follow a Perceptual gradient's curve. `None` when the + /// ink's lightness is within [`MIN_INK_LIGHTNESS_SPAN`] of paper white's (a white or nearly + /// white ink), so lightness does not tell its tints apart, and when the curve has more than + /// [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]). + pub fn ink_tints(&self, ink: &Color) -> Option> { + use crate::libraries::srgb_to_oklab; + let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0]; + if lightness(&ink.tinted(0.0)) - lightness(ink) < MIN_INK_LIGHTNESS_SPAN { + return None; + } + let curve: Vec<_> = self.expanded().take(MAX_INK_TINTS + 1).collect(); + if curve.len() > MAX_INK_TINTS { + return None; + } + let tint = |c: &Color| { + let want = lightness(c); + let (mut lo, mut hi) = (0.0f32, 1.0f32); + for _ in 0..TINT_STEPS { + let mid = (lo + hi) / 2.0; + if lightness(&ink.tinted(mid)) > want { + lo = mid; + } else { + hi = mid; + } + } + (lo + hi) / 2.0 + }; + Some(curve.into_iter().map(|(t, c, o, _)| (t, tint(&c), o)).collect()) + } + /// Does the gradient's curve ([`Self::expanded`]) have at most [`MAX_INK_TINTS`] stops, so + /// [`Self::ink_tints`] finds tints for it? + pub fn fits_ink_tints(&self) -> bool { + self.expanded().nth(MAX_INK_TINTS).is_none() + } /// The same ramp the other way round: the stops in reverse order at mirrored offsets, each /// segment's midpoint mirrored with it. pub fn reverse(&mut self) { diff --git a/crates/color/src/gradient/tests.rs b/crates/color/src/gradient/tests.rs index 19e9f44fc..c2d7d23d7 100644 --- a/crates/color/src/gradient/tests.rs +++ b/crates/color/src/gradient/tests.rs @@ -455,3 +455,34 @@ fn perceptual_gradients_draw_as_pixels() { assert!(GradientPaint::new(radial).draws_as_pixels(fill)); } } + +/// A Perceptual gradient of an ink's tints as tints: its stops keep their tints, and each tint on +/// the curve has the lightness of the color mixed there, so the tints between the stops are not +/// a straight line. +#[test] +fn ink_tints_follow_the_perceptual_curve() { + use crate::libraries::srgb_to_oklab; + let ink = Color::cmyk(0.0, 1.0, 0.0, 0.0); + let mut g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, ink.tinted(0.8)), GradientStop::new(1.0, Color::WHITE)]); + g.method = GradientMethod::Perceptual; + assert!(g.fits_ink_tints()); + let tints = g.ink_tints(&ink).expect("a magenta ink's tints differ in lightness"); + assert!(tints.len() > 2, "the curve's stops: {tints:?}"); + let (first, last) = (tints[0], tints[tints.len() - 1]); + assert!((first.0, last.0) == (0.0, 1.0) && (first.1 - 0.8).abs() < 1e-4 && last.1 < 1e-4, "{tints:?}"); + assert!(tints.windows(2).all(|w| w[0].0 <= w[1].0 && w[0].1 >= w[1].1), "in order, lighter along the way: {tints:?}"); + let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0]; + for (t, tint, _) in &tints { + let want = lightness(&g.sample(*t).0); + assert!((lightness(&ink.tinted(*tint)) - want).abs() < 1e-3, "at {t}: tint {tint}"); + } + let mid = tints.iter().find(|s| (s.0 - 0.5).abs() < 1e-6).expect("the midpoint"); + assert!((mid.1 - 0.4).abs() > 0.01, "not halfway in tint: {mid:?}"); + // A white ink's tints all have paper white's lightness: no tints by lightness. + assert_eq!(g.ink_tints(&Color::WHITE), None); + // More stops on the curve than MAX_INK_TINTS: no tints. + let n = MAX_INK_TINTS + 1; + g.stops = (0..n).map(|i| GradientStop::new(i as f32 / (n - 1) as f32, if i % 2 == 0 { ink } else { Color::WHITE })).collect(); + assert_eq!(g.ink_tints(&ink), None); + assert!(!g.fits_ink_tints()); +} diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index ced9472a7..ce20d29cd 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -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], spec: &BlendSpec, symbols: Option<&crate::SymbolBoxes>) -> Option { + 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 // ===================================================================================== diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index b754e0d56..d20c1ffe4 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -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; + +/// 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)>, + patterns: Vec, + inputs: u64, + art: Rc>, + 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> = 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> { + 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 = const { std::cell::Cell::new(0) }; +} + /// Topmost editable object under `p`. pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option { 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 = const { std::cell::Cell::new(0) }; +} + fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option { + #[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 { + // 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), } } diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index e5ee6c4c8..c4d6c565e 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -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 = (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. diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index e5c98886a..b5ae03e8b 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -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 { 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), } } diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index d53c99e6c..dd84912f0 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -104,6 +104,14 @@ pub struct CharStyle { /// #[serde(default, rename = "tateChuYoko", skip_serializing_if = "crate::skip::is_default")] pub tate_chu_yoko: bool, + /// Standard Vertical Roman Alignment (Character panel menu, East Asian options): in vertical + /// type the half-width characters among these (Latin letters, digits and other characters that + /// would lie on their side) stand upright one below the other instead of being rotated + /// individually. Spaces and tate-chu-yoko blocks do not change, and horizontal type keeps the + /// mark and does not change. + /// + #[serde(default, rename = "standardVerticalRoman", skip_serializing_if = "crate::skip::is_default")] + pub standard_vertical_roman: bool, /// Digits (Character panel menu, Middle Eastern options): the numeral system the digits of /// these characters are drawn in, whatever digits were typed. #[serde(default, skip_serializing_if = "crate::skip::is_default")] @@ -369,6 +377,7 @@ impl Default for CharStyle { char_direction: None, proportional_metrics: false, tate_chu_yoko: false, + standard_vertical_roman: false, small_caps: None, digits: Digits::Default, no_break: false, diff --git a/crates/engine/src/cmd/addons/document.rs b/crates/engine/src/cmd/addons/document.rs index 38aa8fab3..faa02efc2 100644 --- a/crates/engine/src/cmd/addons/document.rs +++ b/crates/engine/src/cmd/addons/document.rs @@ -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 { 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 (.; -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 { fileio::save_document(s, SaveMode::Copy, &json!({ "path": path, "format": format.id })) } +fn export_all_open(s: &mut Session, p: &Value) -> Result { + 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::::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 { diff --git a/crates/engine/src/cmd/addons/document/tests.rs b/crates/engine/src/cmd/addons/document/tests.rs index a67fcd5ae..aef604eb3 100644 --- a/crates/engine/src/cmd/addons/document/tests.rs +++ b/crates/engine/src/cmd/addons/document/tests.rs @@ -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); +} diff --git a/crates/engine/src/cmd/addons/pathtools.rs b/crates/engine/src/cmd/addons/pathtools.rs index 94114a59c..5d0e5723f 100644 --- a/crates/engine/src/cmd/addons/pathtools.rs +++ b/crates/engine/src/cmd/addons/pathtools.rs @@ -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 { 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 { })?; Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "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 { + 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 { + 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)> = 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::>()); + 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::>(), "dashes": ids.len() })) +} diff --git a/crates/engine/src/cmd/addons/pathtools/tests.rs b/crates/engine/src/cmd/addons/pathtools/tests.rs index cc745385c..32bcc0c3b 100644 --- a/crates/engine/src/cmd/addons/pathtools/tests.rs +++ b/crates/engine/src/cmd/addons/pathtools/tests.rs @@ -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 = 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)); +} diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index edccacd50..6f603b200 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -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"; diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 92ce8723b..ccaf3eeee 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "Gradient", ["Window", "Gradient"], None, - "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even, which SVG and PDF exports draw as an image; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg, aspect?: %, reverse?: bool, dither?: bool (fills only: a linear or radial gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", + "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even, which SVG and PDF exports draw as an image, except that PDF writes one made of tints of one spot color in that ink; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg, aspect?: %, reverse?: bool, dither?: bool (fills only: a linear or radial gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", has_doc, edit_gradient ), diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index 8d6530860..f3e25b19e 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { "Make Opacity Mask", ["Window", "Transparency"], None, - "{ids?, clip?, invert?} the topmost of the selected objects (or of `ids`) becomes the mask of the others (grouped if several, a layer among several by its art). One object gets an empty mask and enters mask editing: draw the mask, then transparency.stopEditingOpacityMask; one target (an object or a layer) is refused in isolation mode and in pattern editing mode → {id, editing}", + "{ids?, clip?, invert?} the topmost of the selected objects (or of `ids`) becomes the mask of the others (grouped if several; a layer among several stands for its art, so the topmost object of a layer on top is the mask). One object gets an empty mask and enters mask editing: draw the mask, then transparency.stopEditingOpacityMask; one target (an object or a layer) is refused in isolation mode and in pattern editing mode → {id, editing}", can_make, make ), @@ -185,7 +185,7 @@ fn make(s: &mut Session, p: &Value) -> Result { if d.mask_edit.is_some() { return Err(bad(C, "stop editing the opacity mask first")); } - let ids = match ids_param(p, "ids") { + let mut ids = match ids_param(p, "ids") { Some(ids) => { if let Some(id) = ids.iter().find(|id| d.node(**id).is_none()) { return Err(EngineError::NoNode(*id)); @@ -194,6 +194,27 @@ fn make(s: &mut Session, p: &Value) -> Result { } None => super::appearance::subject_roots(s)?, }; + // A layer among several targets stands for its art that can be edited: visible and unlocked, + // with every layer it is in, and not on a locked artboard. A hidden, locked or template layer + // gives none. + let editable = d.editable(); + let art_of = |id: NodeId| match d.node(id) { + Some(n) if n.is_layer() && (!n.visible || n.locked || n.is_template()) => vec![], + Some(n) if n.is_layer() => n.layer_art(true).into_iter().filter(|c| editable(*c)).collect(), + _ => vec![id], + }; + // A layer on top: the topmost object of its art is the mask, and the rest of it is masked. + if ids.len() > 1 + && let Some(&top) = ids.last() + && d.node(top).is_some_and(Node::is_layer) + { + let top_art = super::edit::outermost(d, d.paint_order(art_of(top))); + if top_art.is_empty() { + return Err(bad(C, "select the art and, on top of it, the mask object")); + } + ids.pop(); + ids.extend(top_art); + } let Some((&top, below)) = ids.split_last() else { return Err(bad(C, "select the art and, on top of it, the mask object")) }; // One target gets an empty mask and enters mask editing, which isolation mode and pattern // editing mode do not allow. @@ -206,17 +227,10 @@ fn make(s: &mut Session, p: &Value) -> Result { let invert = bool_or(p, "invert", s.menu.new_masks_inverted); // Several objects: the top one is the mask. One: an empty mask, drawn in mask editing. let (art, mask_id) = if below.is_empty() { (&ids[..], None) } else { (below, Some(top)) }; - // Several objects go into a new group, and a layer can't: a layer among them stands for its - // visible, unlocked art, which the group takes from it. A hidden, locked or template layer - // gives none. + // Several objects go into a new group, and a layer can't: the group takes a layer's art. let contents; let art = if art.len() > 1 && art.iter().any(|id| d.node(*id).is_some_and(Node::is_layer)) { - let objects = art.iter().flat_map(|id| match d.node(*id) { - Some(n) if n.is_layer() && (!n.visible || n.locked || n.is_template()) => vec![], - Some(n) if n.is_layer() => n.layer_art(true), - _ => vec![*id], - }); - contents = super::edit::outermost(d, d.paint_order(objects)); + contents = super::edit::outermost(d, d.paint_order(art.iter().flat_map(|id| art_of(*id)))); if contents.is_empty() { return Err(bad(C, "the layers hold no art to mask")); } @@ -224,6 +238,7 @@ fn make(s: &mut Session, p: &Value) -> Result { } else { art }; + drop(editable); let (id, layer) = s.edit("Make Opacity Mask", |d, sel| { let mask_art = match mask_id { Some(m) => (*d.remove(m)?).clone(), @@ -549,6 +564,76 @@ mod tests { assert_eq!((d.parent_of(NodeId(b)), d.parent_of(NodeId(b2))), (Some(NodeId(l2)), Some(NodeId(l2)))); } + #[test] + fn make_takes_the_mask_from_the_art_of_a_layer_on_top() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let rect = |s: &mut Session, x: u64| { + s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap() + }; + let l1 = s.doc().unwrap().doc.layers[0].id.0; + let (a, a2) = (rect(&mut s, 0), rect(&mut s, 30)); + let l2 = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap(); + let (b, b2) = (rect(&mut s, 60), rect(&mut s, 90)); + let l3 = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap(); + let top = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 150, "height": 50})).unwrap()["id"].as_u64().unwrap(); + let before = s.doc().unwrap().doc.layers.clone(); + let layer_ids: Vec = before.iter().map(|l| l.id.0).collect(); + // The topmost object of the top layer is the mask, and the rest of that layer's art is + // masked with the art below it. No layer leaves the layer tree. + for (ids, mask, grouped) in [(vec![a, l3], top, None), (vec![l1, l2], b2, Some(vec![a, a2, b])), (vec![a, l2], b2, Some(vec![a, b]))] { + let r = s.execute("transparency.makeOpacityMask", &json!({ "ids": ids })).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + assert_eq!(r["editing"], false, "{ids:?}"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.layers.iter().map(|l| l.id.0).collect::>(), layer_ids, "{ids:?}: every layer stays"); + assert!(d.node(NodeId(mask)).is_none(), "{ids:?}: the mask art left the layer tree"); + let n = d.node(id).unwrap(); + assert_eq!(n.extras().mask.as_ref().unwrap().art.id, NodeId(mask), "{ids:?}"); + match grouped { + Some(g) => assert_eq!(n.children().unwrap().iter().map(|c| c.id.0).collect::>(), g, "{ids:?}"), + None => assert_eq!(id, NodeId(a), "{ids:?}"), + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.layers, before, "{ids:?}: undo"); + } + // A top layer without art gives no mask, and nothing changes. + s.execute("layer.setProps", &json!({"id": l3, "visible": false})).unwrap(); + let hidden = s.doc().unwrap().doc.layers.clone(); + assert!(s.execute("transparency.makeOpacityMask", &json!({ "ids": [a, l3] })).is_err()); + assert_eq!(s.doc().unwrap().doc.layers, hidden); + } + + #[test] + fn make_leaves_out_layer_art_that_cannot_be_edited() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let rect = |s: &mut Session, x: u64, y: u64| { + s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap() + }; + let a = rect(&mut s, 300, 10); + // A visible sublayer of a hidden layer holds no art that can be edited. + let l2 = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap(); + let sub = s.execute("layer.newSublayer", &json!({"parent": l2})).unwrap()["id"].as_u64().unwrap(); + let inner = rect(&mut s, 300, 40); + assert_eq!(s.doc().unwrap().doc.parent_of(NodeId(inner)), Some(NodeId(sub))); + s.execute("layer.setProps", &json!({"id": l2, "visible": false})).unwrap(); + let before = s.doc().unwrap().doc.layers.clone(); + assert!(s.execute("transparency.makeOpacityMask", &json!({ "ids": [a, sub] })).is_err()); + assert_eq!(s.doc().unwrap().doc.layers, before, "the hidden layer's art stays"); + // Art on a locked artboard acts as locked: the topmost object off it is the mask. + let l3 = s.execute("layer.new", &json!({"top": true})).unwrap()["id"].as_u64().unwrap(); + let off = rect(&mut s, 300, 70); + let on = rect(&mut s, 10, 10); + s.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap(); + let r = s.execute("transparency.makeOpacityMask", &json!({ "ids": [a, l3] })).unwrap(); + let d = &s.doc().unwrap().doc; + let n = d.node(NodeId(r["id"].as_u64().unwrap())).unwrap(); + assert_eq!(n.id, NodeId(a)); + assert_eq!(n.extras().mask.as_ref().unwrap().art.id, NodeId(off)); + assert_eq!(d.parent_of(NodeId(on)), Some(NodeId(l3)), "the object on the locked artboard stays"); + } + #[test] fn release_restores_mask_art_and_undo_restores_mask() { let (mut s, a, b) = session_with_two(); diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index f9b9e2c7c..dd71191bf 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\" (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\" (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", has_doc, set_format ), @@ -371,6 +371,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "charDirection", "proportionalMetrics", "tateChuYoko", + "standardVerticalRoman", "noBreak", "digits", "language", @@ -545,6 +546,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = flag("tateChuYoko") { st.tate_chu_yoko = v; } + if let Some(v) = flag("standardVerticalRoman") { + st.standard_vertical_roman = v; + } if let Some(v) = flag("noBreak") { st.no_break = v; } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index f343b6a7c..a7e4f0767 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -39,7 +39,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_range_style ), @@ -319,6 +319,7 @@ pub(crate) struct CharChange { char_direction: Option>, proportional_metrics: Option, tate_chu_yoko: Option, + standard_vertical_roman: Option, no_break: Option, digits: Option, /// The language to set: a tag, or `None` for the default. @@ -471,7 +472,7 @@ impl CharChange { /// `setup` gives the superscript, subscript and small caps proportions. pub(crate) fn parse(p: &Value, cmd: &str, setup: &vectorcraft_doc::DocSetup) -> Result { check_numbers(p, &["size", "tracking", "baselineShift", "hScale", "vScale", "rotation", "strokeWidth"], cmd)?; - check_switches(p, &["underline", "strikethrough", "allCaps", "proportionalMetrics", "tateChuYoko", "noBreak"], cmd)?; + check_switches(p, &["underline", "strikethrough", "allCaps", "proportionalMetrics", "tateChuYoko", "standardVerticalRoman", "noBreak"], cmd)?; let num = |k: &str| p.get(k).and_then(Value::as_f64); let (position, small_caps) = super::docsetup::script_params(p, setup, cmd)?; let flag = |k: &str| p.get(k).and_then(Value::as_bool); @@ -517,6 +518,7 @@ impl CharChange { char_direction: char_direction_param(p, cmd)?, proportional_metrics: flag("proportionalMetrics"), tate_chu_yoko: flag("tateChuYoko"), + standard_vertical_roman: flag("standardVerticalRoman"), no_break: flag("noBreak"), digits: digits_param(p, cmd)?, language: language_param(p, cmd)?, @@ -563,6 +565,7 @@ impl CharChange { && self.char_direction.is_none() && self.proportional_metrics.is_none() && self.tate_chu_yoko.is_none() + && self.standard_vertical_roman.is_none() && self.no_break.is_none() && self.digits.is_none() && self.language.is_none() @@ -668,6 +671,9 @@ impl CharChange { if let Some(v) = self.tate_chu_yoko { st.tate_chu_yoko = v; } + if let Some(v) = self.standard_vertical_roman { + st.standard_vertical_roman = v; + } if let Some(v) = self.no_break { st.no_break = v; } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index e70af82a0..5995c490d 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -175,6 +175,7 @@ pub fn char_attrs(v: &CharStyle) -> Map { ("charDirection", json!(v.char_direction)), ("proportionalMetrics", json!(v.proportional_metrics)), ("tateChuYoko", json!(v.tate_chu_yoko)), + ("standardVerticalRoman", json!(v.standard_vertical_roman)), ("noBreak", json!(v.no_break)), ("digits", json!(v.digits)), ("language", json!(v.language)), diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 1dae041d9..65febdf0b 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -1206,6 +1206,44 @@ fn tate_chu_yoko_is_a_character_attribute_saved_only_when_on() { assert!(runs(&s).iter().all(|r| !r.style.tate_chu_yoko)); } +/// Standard Vertical Roman Alignment (Character panel menu, East Asian options): a character +/// attribute, off by default and saved only when on. Documents written before it read as off; a +/// character style captured from the characters carries it. +#[test] +fn standard_vertical_roman_is_a_character_attribute_saved_only_when_on() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "ABC年"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + assert!(!runs(&s)[0].style.standard_vertical_roman); + assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("standardVerticalRoman").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"standardVerticalRoman": true})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.standard_vertical_roman)); + assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["standardVerticalRoman"], true); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!runs(&s)[0].style.standard_vertical_roman); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 3, "standardVerticalRoman": true})).unwrap(); + let on: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.standard_vertical_roman)).collect(); + assert_eq!(on, [("ABC".to_string(), true), ("年".to_string(), false)]); + let old = serde_json::to_value(&runs(&s)[1].style).unwrap(); + assert!(old.get("standardVerticalRoman").is_none()); + assert!(!serde_json::from_value::(old).unwrap().standard_vertical_roman); + // Saved and reopened as it was. + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; + let reopened: Vec<_> = t.runs.iter().map(|r| (r.text.clone(), r.style.standard_vertical_roman)).collect(); + assert_eq!(reopened, on); + assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[0].style)["standardVerticalRoman"], true); + assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[1].style)["standardVerticalRoman"], false); + assert!(s.execute("text.setRangeStyle", &json!({"id": id, "standardVerticalRoman": "true"})).is_err()); + s.execute("text.setFormat", &json!({"standardVerticalRoman": false})).unwrap(); + assert!(runs(&s).iter().all(|r| !r.style.standard_vertical_roman)); +} + /// No Break is a character attribute, saved only when on. #[test] fn no_break_is_a_character_attribute_saved_only_when_on() { diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index b7e7f51fe..0285ae156 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -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. diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index e5d9ac3cc..811621a5d 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -1463,6 +1463,36 @@ fn rectangular_grid_options_frame_fill_and_skew() { assert!(most.iter().all(|v| *v > 0.0 && *v < 100.0), "{most:?}"); } +/// F, V, X and C pressed while a grid is dragged out change its divider skews by 10 each: the +/// grid drawn on release is the one `shape.rectangularGrid` / `shape.polarGrid` draws with those +/// skews, and they stay as the tool's options. +#[test] +fn grid_drag_keys_skew_the_grid_drawn() { + let v = view(); + for (tool, keys, skews) in [ + ("rectangularGrid", "vvvfcc", json!({"rows": 5, "columns": 5, "rowSkew": 20.0, "columnSkew": 20.0})), + ("polarGrid", "fffxvc", json!({"concentric": 5, "radial": 5, "radialSkew": -20.0, "concentricSkew": 0.0})), + ] { + let mut s = session(); + s.select_tool(tool, v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 200.0), v).unwrap(); + for c in keys.chars() { + assert!(s.tool_claims_key(ToolKey::Letter(c), v), "{tool}: {c}"); + s.tool_key(ToolKey::Letter(c), Mods::default(), v).unwrap(); + } + s.pointer(&PointerEvent::new(PointerKind::Up, 200.0, 200.0), v).unwrap(); + let drawn = s.doc().unwrap().doc.layers[0].children().unwrap().last().unwrap().children().unwrap().to_vec(); + let want = grid(&mut s, &format!("shape.{tool}"), with(&json!({"x": 100, "y": 100, "width": 100, "height": 100}), skews.clone())); + let geometry = |g: &[std::sync::Arc]| g.iter().map(|n| format!("{:?}", n.geometric_bounds())).collect::>(); + assert_eq!(geometry(&drawn), geometry(&want), "{tool}"); + let o = s.tool_options(); + for (k, value) in skews.as_object().unwrap() { + assert_eq!(&o[k], value, "{tool}: {k}"); + } + } +} + /// #1377: the Polar Grid options. The ellipses lie under the radial dividers, largest first; /// `compound` pairs them into compound-path rings, `fill` paints the current fill and the skews /// crowd the ellipses outward and the radial dividers clockwise of the top one (positive). diff --git a/crates/engine/src/tests/linked_stops.rs b/crates/engine/src/tests/linked_stops.rs index 33c622ec7..5adffe77c 100644 --- a/crates/engine/src/tests/linked_stops.rs +++ b/crates/engine/src/tests/linked_stops.rs @@ -192,3 +192,78 @@ fn linked_stops_round_trip() { assert_eq!(old, GradientStop::new(0.5, Color::rgb(1.0, 0.0, 0.0))); assert!(!serde_json::to_string(&old).unwrap().contains("tint")); } + +/// `stops` with `extra` (method, dither…) as a gradient edit. +fn with(mut stops: Value, extra: Value) -> Value { + if let (Some(g), Some(e)) = (stops.as_object_mut(), extra.as_object()) { + g.extend(e.clone()); + } + stops +} + +#[test] +fn perceptual_spot_gradients_export_as_separation_shadings() { + let (mut s, _) = setup(); + let ink_to_tint = json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 1, "swatch": "Ink", "tint": 20}]}); + for (what, stops) in [("Ink → paper", ink_to_white(80.0)), ("Ink → Ink 20 %", ink_to_tint)] { + run(&mut s, "paint.editGradient", with(stops, json!({"method": "perceptual"}))); + let (pdf, warnings) = pdf_of(&s); + assert!(!pdf.contains("/Subtype/Image"), "{what}: no image"); + assert!(pdf.contains("/ShadingType 2") && pdf.contains("/Separation/Ink/DeviceCMYK"), "{what}: a Separation shading"); + // The curve's stops: a stitching function of more than the two ends. + assert!(pdf.contains("/FunctionType 3"), "{what}: tints on the curve"); + assert!(!warnings.iter().any(|w| w.contains("spot")), "{what}: {warnings:?}"); + } + // A radial one, and one on a stroke, too. + run(&mut s, "paint.editGradient", with(ink_to_white(100.0), json!({"method": "perceptual", "kind": "radial"}))); + let (pdf, _) = pdf_of(&s); + assert!(!pdf.contains("/Subtype/Image") && pdf.contains("/Separation/Ink/DeviceCMYK"), "radial"); + run(&mut s, "paint.setFill", json!({"color": "#ffffff"})); + run(&mut s, "paint.setStroke", json!({"color": "#000000"})); + run(&mut s, "stroke.set", json!({"weight": 6})); + run(&mut s, "paint.editGradient", with(ink_to_white(100.0), json!({"method": "perceptual", "stroke": true}))); + let (pdf, _) = pdf_of(&s); + assert!(!pdf.contains("/Subtype/Image") && pdf.contains("/Separation/Ink/DeviceCMYK"), "stroke"); + // A white spot ink has paper white's lightness at every tint: the stops keep their own tints. + run(&mut s, "swatch.new", json!({"name": "Opaque", "color": "#ffffff", "spot": true})); + run(&mut s, "paint.setStroke", json!({"none": true})); + let white = + json!({"stops": [{"offset": 0, "swatch": "Opaque"}, {"offset": 1, "swatch": "Opaque", "tint": 40}], "method": "perceptual", "stroke": false}); + run(&mut s, "paint.editGradient", white); + let (pdf, _) = pdf_of(&s); + let at = pdf.find("/FunctionType").unwrap_or(0); + assert!(!pdf.contains("/Subtype/Image") && pdf.contains("/Separation/Opaque"), "white ink: {}", &pdf[at..(at + 200).min(pdf.len())]); + assert!(pdf.contains("/C0[1]") && pdf.contains("/C1[0.4"), "white ink at 100% to 40%: {}", &pdf[at..(at + 200).min(pdf.len())]); +} + +#[test] +fn spot_gradients_drawn_as_images_warn() { + let (mut s, _) = setup(); + let spot_warning = |w: &[String]| w.iter().any(|w| w.contains("spot colors in gradients exported as images")); + let ink_to_cyan = json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 1, "color": {"c": 1, "m": 0, "y": 0, "k": 0}}]}); + // More stops than the export finds tints for: an image, as before. + let n = 4097; + let stops: Vec = (0..n) + .map(|i| { + let offset = i as f64 / (n - 1) as f64; + if i % 2 == 0 { json!({"offset": offset, "swatch": "Ink"}) } else { json!({"offset": offset, "color": "#ffffff"}) } + }) + .collect(); + for (what, edit) in [ + ("Perceptual Ink → cyan", with(ink_to_cyan, json!({"method": "perceptual"}))), + ("dithered Classic Ink → paper", with(ink_to_white(100.0), json!({"dither": true, "method": "classic"}))), + ("dithered Perceptual Ink → paper", with(ink_to_white(100.0), json!({"dither": true, "method": "perceptual"}))), + ("Perceptual Ink and paper, 4,097 stops", json!({"stops": stops, "dither": false, "method": "perceptual"})), + ] { + run(&mut s, "paint.editGradient", edit); + let (pdf, warnings) = pdf_of(&s); + assert!(pdf.contains("/Subtype/Image"), "{what}: an image"); + assert!(spot_warning(&warnings), "{what}: {warnings:?}"); + } + // A Perceptual gradient without spot inks is an image, without the warning. + let blue_yellow = json!({"stops": [{"offset": 0, "color": "#0000ff"}, {"offset": 1, "color": "#ffff00"}]}); + run(&mut s, "paint.editGradient", with(blue_yellow, json!({"method": "perceptual", "dither": false}))); + let (pdf, warnings) = pdf_of(&s); + assert!(pdf.contains("/Subtype/Image") && !pdf.contains("/Separation"), "a process Perceptual gradient"); + assert!(!spot_warning(&warnings), "{warnings:?}"); +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 330fd4540..38a60e239 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -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})), diff --git a/crates/engine/tests/text_import_fuzz.rs b/crates/engine/tests/text_import_fuzz.rs index 3ea6f94d9..1ea656a15 100644 --- a/crates/engine/tests/text_import_fuzz.rs +++ b/crates/engine/tests/text_import_fuzz.rs @@ -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::>()), 1..60), + compressed in any::(), + v3 in any::(), + large in any::(), + 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) -> Result { + 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 { + 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, 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 { + 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::(), + ] +} + +/// A [`CfbDamage`]. +fn arb_cfb_damage() -> impl Strategy { + let index = any::; + 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::()).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, diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index 5e60e7e81..50fcec241 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -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; diff --git a/crates/eps/src/import/ai/obj.rs b/crates/eps/src/import/ai/obj.rs index d91f153a8..10027f010 100644 --- a/crates/eps/src/import/ai/obj.rs +++ b/crates/eps/src/import/ai/obj.rs @@ -68,6 +68,8 @@ pub(super) struct Obj { pub content: Vec, /// Vector art enclosed by `X=` … `X+` in an opacity-mask dictionary. pub art: Option>, + /// What that art has that isn't read, reported once the mask is known to be enabled. + pub unread: Vec, } impl Obj { diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 5cf477d75..8e7753dd7 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -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, + /// Top-level object ids and the unread kinds within them. + pub unread: Vec<(NodeId, Vec)>, + /// 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, /// 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, /// What layers that don't show have that this reader doesn't read. hidden_unread: BTreeSet, + unread: Vec<(NodeId, Vec)>, + symbol_bounds: HashMap, + /// 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, /// 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() { diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs index 89df83cd4..3f60ebe11 100644 --- a/crates/eps/src/import/ai/read/art.rs +++ b/crates/eps/src/import/ai/read/art.rs @@ -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(), diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index 992a9c564..39f398a2e 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -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 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, Vec) = 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, 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`]). diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index d2a4b6cc5..1d5806a3c 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -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) -> Vec { @@ -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()); + } +} diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index fb540f1d1..eeaf10854 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -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, diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 9deba8889..8eff92907 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -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; } } diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 6d948ae81..a2792e84c 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -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, /// 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, /// 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)>, } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 34b35e13e..21227eef6 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -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::>()); + 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::(); + 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::(); + 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::(); + 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\ diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index efbb8a3a1..8744db0ea 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -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 = 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"); diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 1dd8b7d2b..0ed2f354f 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -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> { - 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 = 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]| { 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 = + 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>| !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, + 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, live: &mut Vec) { + 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, pages: &[Rect], moves: &[Affine], art: &mut Vec, 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, patterns: &mut BTreeSet, swatches: &mut BTreeSet) { + 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 { + 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 = 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)], + elsewhere: &[(NodeId, Rect)], + shown: &mut Vec>>, +) -> Vec>> { + 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)], + elsewhere: &[(NodeId, Rect)], + threaded_frames: &BTreeSet, + copied: &mut BTreeSet, +) -> Result, String> { + fn holds_threaded_frame(node: &Node, frames: &BTreeSet) -> 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) { + ids.insert(node.id); + for child in node.children().into_iter().flatten() { + subtree(child, ids); + } + } + + let unread_ids: BTreeSet = 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 = frame.pages.iter().map(|(page, _)| *page).collect(); + let moves: Vec = frame.pages.iter().map(|(_, shift)| *shift).collect(); + let destination_pages: Vec = 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 = (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::::new(); + let mut counts = std::collections::BTreeMap::::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 = + 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::>()) + { + 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::>())?; + 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, ©))); + 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 { 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>> = 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::>(), [nodes[0].id]); + assert_eq!(shown.iter().flatten().map(|n| n.id).collect::>(), [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)); + } +} diff --git a/crates/eps/src/import/native/slots.rs b/crates/eps/src/import/native/slots.rs index e6dc909ac..188a1b6e6 100644 --- a/crates/eps/src/import/native/slots.rs +++ b/crates/eps/src/import/native/slots.rs @@ -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)) diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 29e73bbc9..9ef30473e 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -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 { 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 diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 7192d8208..f61b8839c 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -1492,6 +1492,56 @@ fn c_and_f_during_an_arc_gesture_go_to_the_arc_tool() { assert_ne!(serde_json::from_str::(&text_of(&r)).unwrap()["handledBy"], "tool", "{r}"); } +/// `press_key` with `f`, `v`, `x` and `c` while a rectangular or polar grid is being dragged goes +/// to the grid tool, and the release draws one grid. With nothing dragged, V is a shortcut again. +#[test] +fn f_v_x_and_c_during_a_grid_gesture_go_to_the_grid_tool() { + let count = |s: &mut Server, id: u64| { + let r = call(s, id, "inspect_document", json!({"depth": 0})); + serde_json::from_str::(&text_of(&r)).unwrap()["layers"][0]["childCount"].as_u64().unwrap() + }; + for tool in ["rectangularGrid", "polarGrid"] { + let mut s = server(); + let before = count(&mut s, 1); + let begin = json!([{"kind": "down", "x": 100, "y": 100}, {"kind": "drag", "x": 200, "y": 180}]); + let r = call(&mut s, 2, "pointer_gesture", json!({"tool": tool, "events": begin})); + assert_eq!(r["isError"], false, "{r}"); + for (id, key) in [(3, "f"), (4, "v"), (5, "x"), (6, "c")] { + let r = call(&mut s, id, "press_key", json!({"key": key})); + assert_eq!(serde_json::from_str::(&text_of(&r)).unwrap()["handledBy"], "tool", "{tool} {key}: {r}"); + } + let r = call(&mut s, 7, "pointer_gesture", json!({"tool": tool, "events": [{"kind": "up", "x": 200, "y": 180}]})); + assert_eq!(r["isError"], false, "{r}"); + assert_eq!(count(&mut s, 8), before + 1, "{tool}"); + let r = call(&mut s, 9, "press_key", json!({"key": "v"})); + assert_ne!(serde_json::from_str::(&text_of(&r)).unwrap()["handledBy"], "tool", "{tool}: {r}"); + } +} + +/// Grid letters with Shift reach the grid tool during a drag; with Ctrl, Cmd or Alt they do not. +#[test] +fn shifted_grid_letters_during_a_drag_go_to_the_grid_tool() { + let mut s = server(); + let r = call( + &mut s, + 1, + "pointer_gesture", + json!({"tool": "polarGrid", "events": [{"kind": "down", "x": 100, "y": 100}, {"kind": "drag", "x": 200, "y": 180}]}), + ); + assert_eq!(r["isError"], false, "{r}"); + for (id, key, mods, tool) in [ + (2, "v", json!({"shift": true}), true), + (3, "f", json!({"ctrl": true}), false), + (4, "x", json!({"cmd": true}), false), + (5, "c", json!({"cmd": true}), false), + (6, "v", json!({"alt": true}), false), + ] { + let r = call(&mut s, id, "press_key", json!({"key": key, "mods": mods})); + let handled = serde_json::from_str::(&text_of(&r)).ok().is_some_and(|v| v["handledBy"] == "tool"); + assert_eq!(handled, tool, "{key} {mods}: {r}"); + } +} + /// A click on one of two selected objects makes it the key object: `pointer_gesture` replies /// with it as `key` and `inspect_document` lists it, and a click on the key again lets it go. #[test] diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index c7090e8e2..5dc0a73af 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -348,11 +348,12 @@ impl Exporter<'_> { /// Is `p` a paint PDF has no paint for when it paints a fill (`fill`) or a stroke, which /// [`Self::area`] draws as art: a pattern swatch or a gradient drawn as pixels - /// ([`vectorcraft_color::GradientPaint::draws_as_pixels`])? + /// ([`vectorcraft_color::GradientPaint::draws_as_pixels`]), but not a Perceptual one of tints + /// of one spot ink ([`Self::perceptual_spot`])? pub(super) fn area_paint(&self, p: &Paint, fill: bool) -> bool { match p { Paint::Pattern { pattern, .. } => self.doc.pattern(pattern).is_some(), - Paint::Gradient(g) => g.draws_as_pixels(fill), + Paint::Gradient(g) => g.draws_as_pixels(fill) && !self.perceptual_spot(g, fill), _ => false, } } diff --git a/crates/pdf/src/export/paint.rs b/crates/pdf/src/export/paint.rs index b7c303487..dc2b3b550 100644 --- a/crates/pdf/src/export/paint.rs +++ b/crates/pdf/src/export/paint.rs @@ -195,7 +195,10 @@ impl Exporter<'_> { Paint::Solid { color: c, swatch, tint } => Some(self.solid(c, swatch.as_deref(), *tint).into()), Paint::Gradient(g) => { let geom = g.resolve(bounds); - let spot = (g.gradient.kind != GradientKind::Freeform).then(|| self.spot_stops(&g.gradient)).flatten(); + let spot = match g.gradient.kind { + GradientKind::Freeform => None, + _ => self.perceptual_spot_stops(&g.gradient).or_else(|| self.spot_stops(&g.gradient)), + }; let colored = match spot { Some(v) => v, None => { @@ -333,6 +336,50 @@ impl Exporter<'_> { Some(krilla::paint::Pattern { stream: sb.finish(), transform: xf(at), width: pw as f32, height: ph as f32 }.into()) } + /// The spot swatch whose tints are every stop of `g` that isn't paper white, unlinked; `None` + /// when no stop is linked to a spot swatch or a stop has another color. + fn one_spot_ink<'g>(&self, g: &'g vectorcraft_color::Gradient) -> Option<&'g str> { + let spot = |s: &'g vectorcraft_color::GradientStop| s.swatch.as_deref().filter(|n| self.doc.swatch(n).is_some_and(|w| w.spot)); + let ink = g.stops.iter().find_map(spot)?; + let paper = |s: &vectorcraft_color::GradientStop| s.swatch.is_none() && s.color.to_rgba8(1.0)[..3] == [255; 3]; + g.stops.iter().all(|s| spot(s) == Some(ink) || paper(s)).then_some(ink) + } + + /// Is `g` a Perceptual linear or radial gradient of tints of one spot ink that paints a fill + /// (`fill`) without dither or a stroke, with a curve short enough for + /// [`vectorcraft_color::Gradient::fits_ink_tints`], which [`Self::perceptual_spot_stops`] + /// writes as a Separation shading rather than an image? + pub(super) fn perceptual_spot(&self, g: &vectorcraft_color::GradientPaint, fill: bool) -> bool { + let gr = &g.gradient; + !gr.method.is_classic() && gr.kind != GradientKind::Freeform && !(fill && gr.dither) && self.one_spot_ink(gr).is_some() && gr.fits_ink_tints() + } + + /// The stops of a Perceptual gradient of tints of one spot ink (or paper white, 0% of it) as + /// tints of that ink, a Separation shading: the stops on its curve with the tints whose + /// lightness is the curve's there ([`vectorcraft_color::Gradient::ink_tints`]). `None` for + /// other gradients, and for an ink too light for lightness to tell its tints apart, which + /// [`Self::spot_stops`] then writes with the stops' own tints. + fn perceptual_spot_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option> { + if g.method.is_classic() { + return None; + } + let ink = self.one_spot_ink(g)?; + let doc = self.doc; + let full = doc.linked_color(doc.swatch(ink)?.paint.color()?, true); + let space = self.separation(ink)?; + Some(g.ink_tints(&full)?.into_iter().map(|(o, t, a)| (o, SepColor::new(q(t), space.clone()).into(), a)).collect()) + } + + /// Warn when gradient `g`, drawn as an image, has stops linked to a spot swatch: the image is + /// in process colors, so the spot inks have no plate of their own. + fn warn_spot_image(&mut self, g: &vectorcraft_color::GradientPaint) { + let doc = self.doc; + let from_stops = g.gradient.kind != GradientKind::Freeform || g.freeform.is_none(); + if from_stops && g.gradient.stops.iter().any(|s| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot))) { + self.warn("spot colors in gradients exported as images (freeform, dithered or Perceptual gradients that mix inks) are written in process colors"); + } + } + /// Draw gradient `g`, placed on `bounds`, as an image over `area`, sampled at the document's /// raster effects resolution where it is drawn (`area` as the current transform sizes it, so /// scaled type gets its pixels too): a freeform gradient's colour field (at most @@ -349,6 +396,7 @@ impl Exporter<'_> { if !(long > 1e-9 && long.is_finite()) { return; } + self.warn_spot_image(g); let freeform = g.gradient.kind == GradientKind::Freeform; let max = if freeform { MAX_FIELD_PX } else { MAX_GRADIENT_PX }; let k = (self.doc.raster_effects_ppi / 72.0).min(max / long); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 66cdaf304..f217eaa3a 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -41,6 +41,12 @@ use vertical::{stands_upright, tate_chu_yoko, upright_cell, upright_centre, whol const EPS: f64 = 1e-6; +/// Is glyph `g` a later glyph of the cluster of glyph `prev` before it (a combining mark the shaper +/// set as a glyph of its own)? A list marker's glyphs stand for no character and are not. +fn continues_cluster(prev: Option<&SGlyph>, g: Option<&SGlyph>) -> bool { + prev.zip(g).is_some_and(|(a, b)| a.len > 0 && b.len > 0 && a.byte == b.byte) +} + struct Ctx<'a> { on_path: bool, db: &'a FontDb, @@ -58,6 +64,10 @@ struct Ctx<'a> { system_layout: bool, /// Character panel menu › Fractional Widths. Off, positive advances are whole points. fractional_widths: bool, + /// Vertical type: the upright glyph [`Self::emit`] placed last, as (its byte, the index the + /// next glyph of its cluster would take, the turn it got, the line-space x that next glyph is + /// set after), so that a combining mark shaped into the same cluster turns with it. + upright_cluster: Option<(usize, usize, Affine, f64)>, out: TextLayout, } @@ -108,8 +118,11 @@ impl Ctx<'_> { }); // An upright glyph advances down the column by its vertical advance (the font's vertical // metrics; without them at least one em, as CJK fonts' is), centred across it: a narrow - // mark like § must not overlap its neighbours. - for g in v.iter_mut().filter(|g| g.adv > 0.0 && stands_upright(g)) { + // mark like § must not overlap its neighbours. A combining mark shaped into the cluster + // of the glyph before it takes no cell of its own: it turns with that glyph (`emit`). + for i in 0..v.len() { + let continues = continues_cluster(i.checked_sub(1).and_then(|p| v.get(p)), v.get(i)); + let Some(g) = v.get_mut(i).filter(|g| g.adv > 0.0 && !continues && stands_upright(g)) else { continue }; let extra = upright_cell(g) - g.face.advance(g.gid) * g.sx; g.dx += extra * 0.5; g.adv += extra; @@ -181,6 +194,8 @@ impl Ctx<'_> { } fn emit(&mut self, g: &SGlyph, pre: Affine, origin: Point, angle: f64, advance: f64, line: usize) { + // Only the glyph right after an upright glyph can continue its cluster. + let cluster = self.upright_cluster.take(); // Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef). let blank = g.is_soft_hyphen() || g.ch.is_control() || g.ch == '\u{2028}'; // A colour glyph draws its art (COLR layers, an SVG document) or its image (a bitmap, or @@ -235,16 +250,29 @@ impl Ctx<'_> { let squeeze = Affine::translate((centre.x, 0.0)) * Affine::scale_non_uniform(t.squeeze, 1.0) * Affine::translate((-centre.x, 0.0)); m = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, centre) * squeeze * across * m; } else if self.vertical && stands_upright(g) { - // Turned about the centre of its cell, which the column's centre line runs through: the - // middle of its own cell, not of its advance (tracking and justification add space - // after the cell, and must not push the glyph off the centre line). - let em = self.style_at(g.byte).size; - let cell = if g.adv > 0.0 { upright_cell(g) } else { advance }; - // A leading space taken off moves it up the column after the turn (moving the - // turning point instead would move it across the column too). - m = Affine::translate((-g.lead, 0.0)) - * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + cell * 0.5, origin.y - upright_centre(g, cell, em))) - * m; + let next = self.out.glyphs.len(); + let after_base = self.out.glyphs.last().is_some_and(|p| p.byte == g.byte && p.line == line && p.len > 0); + if let Some((_, _, turn, after)) = cluster.filter(|c| c.0 == g.byte && c.1 == next && after_base && g.len > 0) { + // A later glyph of an upright glyph's cluster: set after it as the shaper placed + // it, and turned with it. + m = turn * Affine::translate((after - origin.x, 0.0)) * m; + self.upright_cluster = Some((g.byte, next + 1, turn, after)); + } else { + // Turned about the centre of its cell, which the column's centre line runs through: + // the middle of its own cell, not of its advance (tracking and justification add + // space after the cell, and must not push the glyph off the centre line). + let em = self.style_at(g.byte).size; + let cell = if g.adv > 0.0 { upright_cell(g) } else { advance }; + // A leading space taken off moves it up the column after the turn (moving the + // turning point instead would move it across the column too). + let turn = Affine::translate((-g.lead, 0.0)) + * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + cell * 0.5, origin.y - upright_centre(g, cell, em))); + m = turn * m; + // Its own advance across, from where the cell centres it: where the shaper set the + // next glyph of its cluster. + let across = g.face.advance(g.gid) * g.sx; + self.upright_cluster = (g.len > 0).then_some((g.byte, next + 1, turn, origin.x + (cell - across) * 0.5 + across)); + } } let outline = if src.elements().is_empty() || blank { BezPath::new() diff --git a/crates/text/src/layout/driver.rs b/crates/text/src/layout/driver.rs index 03a5d6617..4cb518d0d 100644 --- a/crates/text/src/layout/driver.rs +++ b/crates/text/src/layout/driver.rs @@ -228,6 +228,7 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout vertical, system_layout: t.system_layout, fractional_widths: t.fractional_widths, + upright_cluster: None, out: TextLayout::default(), }; // Vertical type: line space turned a quarter turn clockwise (lines become columns, each next diff --git a/crates/text/src/layout/vertical.rs b/crates/text/src/layout/vertical.rs index 0953ae656..61251d76e 100644 --- a/crates/text/src/layout/vertical.rs +++ b/crates/text/src/layout/vertical.rs @@ -218,6 +218,12 @@ pub(super) const QUARTER_TURN: Affine = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0 /// their side when the font has none (Unicode's Vertical_Orientation Tr). /// Fullwidth !/? instead keep the nominal glyph upright (Tu fallback, UAX #50 §3.1): /// . +/// +/// Standard Vertical Roman Alignment (Character panel menu): a marked Latin letter, digit or other +/// character outside the classes above stands upright instead of lying on its side, except spaces +/// and control characters (they keep their width down the column) and the glyphs of a +/// tate-chu-yoko block, which stays across the column. +/// pub(super) fn stands_upright(g: &SGlyph) -> bool { let c = g.ch as u32; let upright = matches!(c, @@ -230,7 +236,7 @@ pub(super) fn stands_upright(g: &SGlyph) -> bool { | 0xE000..=0xFAFF | 0xFE10..=0xFE1F | 0xFE30..=0xFE4F | 0xFE50..=0xFE6F | 0xFF00..=0xFFE7 | 0x1F000..=0x1FAFF | 0x20000..=0x3FFFF); if !upright { - return false; + return g.vertical_roman && g.tcy.is_none() && g.inline.is_none() && !g.ch.is_whitespace() && !g.ch.is_control(); } let needs_vertical_form = matches!(c, 0x3001 | 0x3002 | 0x3008..=0x3011 | 0x3013..=0x301F | 0x3030 | 0x30A0 | 0x30FC diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index 6be50fbd8..360c386ea 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -36,6 +36,7 @@ fn synthetic_glyphs(specs: &[(usize, usize, f64, char)]) -> Vec { vpal: None, level: unicode_bidi::Level::ltr(), no_break: false, + vertical_roman: false, cursive: false, }) .collect() diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index f0fbb0c4d..e04c6c6dc 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -69,6 +69,9 @@ pub(crate) struct SGlyph { /// No Break (Character panel menu): keep this glyph on the same line as the neighbouring /// glyphs that have it. pub no_break: bool, + /// Standard Vertical Roman Alignment (Character panel menu): in vertical type this glyph + /// stands upright when it would otherwise lie on its side ([`crate::layout`]). + pub vertical_roman: bool, /// The glyph is of a script whose letters join (Arabic, Syriac, N'Ko, Mongolian, Adlam…), /// decided for the whole segment it was shaped in ([`joins`]): on a curved path it is bent /// along the path so its joins meet. @@ -713,6 +716,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { char_direction: _, proportional_metrics, tate_chu_yoko, + standard_vertical_roman: _, digits, no_break, language, @@ -828,6 +832,7 @@ fn inline_glyph(face: &Arc, st: &CharStyle, art: &InlineArt, run: usiz inline: Some(InlineBox { art: drawn, xf }), word_end: false, no_break: st.no_break, + vertical_roman: false, cursive: false, } } @@ -1137,6 +1142,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe inline: None, word_end: false, no_break: gst.no_break, + vertical_roman: gst.standard_vertical_roman, cursive, }); } diff --git a/crates/text/src/tests/vertical.rs b/crates/text/src/tests/vertical.rs index a2206df5e..1010ed84a 100644 --- a/crates/text/src/tests/vertical.rs +++ b/crates/text/src/tests/vertical.rs @@ -379,6 +379,60 @@ fn manual_tate_chu_yoko_leaves_unmarked_digits_and_stays_on_one_line() { assert!(wrapped.lines.len() > 1, "unmarked 2026 is longer than the column"); } +/// Character panel menu › Standard Vertical Roman Alignment: marked Latin letters and digits stand +/// upright one below the other, each in a cell of one em (the bundled font has no vertical +/// metrics), centred on the column. A space keeps its width, two digits stay tate-chu-yoko, +/// unmarked letters lie on their side, and horizontal type is unchanged. +#[test] +fn standard_vertical_roman_alignment_stands_half_width_characters_upright() { + let plain = CharStyle { size: 20.0, ..CharStyle::default() }; + let marked = CharStyle { standard_vertical_roman: true, ..plain.clone() }; + let h = layout(FontDb::global(), &TextObject::point(Point::ZERO, "l", plain.clone())); + let side = layout(FontDb::global(), &vertical_styled("lA l", plain.clone())); + let up = layout(FontDb::global(), &vertical_styled("lA l", marked.clone())); + let (hl, sl, ul) = (ink(&h, 0), ink(&side, 0), ink(&up, 0)); + assert!(sl.width() > sl.height(), "unmarked l lies across the column: {sl:?}"); + assert!((ul.width() - hl.width()).abs() < 0.01 && (ul.height() - hl.height()).abs() < 0.01, "marked l upright: {hl:?} {ul:?}"); + assert!(ul.height() > ul.width()); + for i in [0, 1, 3] { + assert!((up.glyphs[i].advance - 20.0).abs() < 1e-6, "one em down the column: {:?}", up.glyphs[i].advance); + assert!(ink(&up, i).center().x.abs() < 1.5, "centred on the column: {:?}", ink(&up, i)); + assert!(side.glyphs[i].advance < 20.0, "unmarked advance: {:?}", side.glyphs[i].advance); + } + assert!(ink(&up, 1).center().y > ink(&up, 0).center().y + 15.0, "one below the other"); + assert!((up.glyphs[2].advance - side.glyphs[2].advance).abs() < 1e-6, "the space keeps its width"); + + let digits = layout(FontDb::global(), &vertical_styled("10", marked.clone())); + assert!((digits.glyphs[0].advance - 20.0).abs() < 1e-6 && digits.glyphs[1].advance == 0.0, "two digits stay tate-chu-yoko"); + let (a, b) = (ink(&digits, 0), ink(&digits, 1)); + assert!((a.center().y - b.center().y).abs() < 1.0 && b.center().x > a.center().x, "side by side: {a:?} {b:?}"); + + let mut horizontal = TextObject::point(Point::ZERO, "lA", marked); + horizontal.xf = Affine::IDENTITY; + let row = layout(FontDb::global(), &horizontal); + let plain_row = layout(FontDb::global(), &TextObject::point(Point::ZERO, "lA", plain)); + assert_eq!(row.glyphs.iter().map(|g| g.advance).collect::>(), plain_row.glyphs.iter().map(|g| g.advance).collect::>()); + assert!((ink(&row, 0).height() - hl.height()).abs() < 0.01, "horizontal type stays as it is"); +} + +/// A combining mark shaped as a glyph of its own turns upright with its letter and stays over it, +/// as in horizontal type, and takes no cell of its own down the column. +#[test] +fn standard_vertical_roman_alignment_keeps_combining_marks_on_their_letter() { + let plain = CharStyle { size: 20.0, ..CharStyle::default() }; + let text = "q\u{301}x"; + let h = layout(FontDb::global(), &TextObject::point(Point::ZERO, text, plain.clone())); + assert_eq!(h.glyphs.len(), 3, "the bundled font sets the acute as a glyph of its own"); + let up = layout(FontDb::global(), &vertical_styled(text, CharStyle { standard_vertical_roman: true, ..plain })); + assert_eq!(up.glyphs.len(), 3); + let (hq, ha, q, a, x) = (ink(&h, 0), ink(&h, 1), ink(&up, 0), ink(&up, 1), ink(&up, 2)); + let offset = |base: Rect, mark: Rect| (mark.center().x - base.center().x, mark.center().y - base.center().y); + let (want, got) = (offset(hq, ha), offset(q, a)); + assert!((want.0 - got.0).abs() < 0.01 && (want.1 - got.1).abs() < 0.01, "the acute sits on the q as in a row: {want:?} {got:?}"); + assert!((up.glyphs[0].advance - 20.0).abs() < 1e-6 && up.glyphs[1].advance == 0.0, "one cell for the cluster"); + assert!(x.center().y > q.center().y + 10.0, "the next letter below: {q:?} {x:?}"); +} + /// Issue #260 as reported, with a Japanese face of craft-fonts (whose own digits are proportional). #[test] fn japanese_numbers_keep_their_own_cells_down_the_column() { diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index 5903ac5b3..2e6164a4c 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -36,7 +36,7 @@ use cache::RegionCache; #[cfg(test)] use live_paint::LpHover; use live_paint::lp_color; -pub use live_paint::{LP_SELECT_WIDTH, LP_SELECT_WIDTH_RANGE, LivePaintBucketTool, LivePaintSelectionTool}; +pub use live_paint::{LP_BUCKET_WIDTH, LP_BUCKET_WIDTH_RANGE, LP_SELECT_WIDTH, LP_SELECT_WIDTH_RANGE, LivePaintBucketTool, LivePaintSelectionTool}; pub use lookup::{ EDGE_NAME, FACE_NAME, Face, LIVE_PAINT_NAME, LIVE_PAINT_PREFIX, SOURCES_NAME, builder_members, drawn_outlines, edge_near, edges, face_at, faces, is_live_paint, leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap, path_contains, shape_builder_shapes, shapes_for, diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index 7265122c3..4d201acc9 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -86,20 +86,91 @@ fn lp_overlays(h: &LpHover, cache: &RegionCache, color: [u8; 3], width: f32) -> } } -/// Live Paint Bucket: click fills the face under the cursor with the current fill; Shift-click -/// paints the nearest edge with the current stroke. Alt-click samples a fill or stroke into the -/// paint defaults; Alt+Shift-click samples only the color under the pointer. Clicking selected -/// ordinary paths without Alt makes them a Live Paint group first. +/// The Live Paint Bucket's highlight width (screen pixels) by default. +pub const LP_BUCKET_WIDTH: f64 = 4.0; + +/// The least and greatest highlight width (screen pixels) the Live Paint Bucket takes. +pub const LP_BUCKET_WIDTH_RANGE: (f64, f64) = (1.0, 20.0); + +/// What a Live Paint Bucket click paints. +#[derive(Clone, Copy, Debug, PartialEq)] +enum LpPaint { + Nothing, + /// The face under the pointer. + Fills, + /// The edge nearest the pointer. + Strokes, + /// The edge nearest the pointer when one is in reach, else the face under the pointer. + Both, +} + +/// Live Paint Bucket: by default, a click fills the face under the cursor with the current fill, +/// and Shift-click paints the nearest edge with the current stroke. Alt-click samples a fill or +/// stroke into the paint defaults; Alt+Shift-click samples only the color under the pointer. +/// Clicking selected ordinary paths without Alt makes them a Live Paint group first. Its Tool +/// Options (double-click the tool) are `paintFills` (on) and `paintStrokes` (off), `highlight` +/// (on), the highlight `color` (`#rrggbb`, red by default) and its `width` ([`LP_BUCKET_WIDTH`] +/// pixels). With Paint Strokes alone on, a click paints the edge nearest the pointer and +/// Shift-click fills the face. With both on, a click paints the edge nearest the pointer when one +/// is within 4 pixels, else it fills the face, and Shift-click fills the face. With both off, a +/// click paints nothing. With Highlight off, the face or edge under the pointer is not +/// highlighted. pub struct LivePaintBucketTool { pub(super) cache: RegionCache, pub(super) hover: LpHover, /// Where the pointer last moved, and whether Shift and Alt were held. at: Option<(Point, bool, bool)>, + fills: bool, + strokes: bool, + highlight: bool, + color: [u8; 3], + width: f64, } impl Default for LivePaintBucketTool { fn default() -> Self { - Self { cache: RegionCache::live_paint(), hover: LpHover::None, at: None } + Self { + cache: RegionCache::live_paint(), + hover: LpHover::None, + at: None, + fills: true, + strokes: false, + highlight: true, + color: HIGHLIGHT_RED, + width: LP_BUCKET_WIDTH, + } + } +} + +impl LivePaintBucketTool { + /// What a click paints, Shift held or not. Shift switches Paint Fills alone to Paint Strokes + /// alone and back, and switches both to Paint Fills alone. + fn paints(&self, shift: bool) -> LpPaint { + match (self.fills, self.strokes, shift) { + (false, false, _) => LpPaint::Nothing, + (true, true, false) => LpPaint::Both, + (true, false, false) | (_, true, true) => LpPaint::Fills, + (true, false, true) | (false, true, false) => LpPaint::Strokes, + } + } + + /// The highlight at `p` for a pointer with Shift and Alt held as given: none with Alt or with + /// Highlight off, else the face or edge a click there paints. + fn hover_at(&mut self, cx: &ToolContext, p: Point, shift: bool, alt: bool) -> LpHover { + if alt || !self.highlight { + return LpHover::None; + } + let takes = |id| lp_visible(cx, id); + match self.paints(shift) { + LpPaint::Nothing => LpHover::None, + LpPaint::Fills => lp_hover(cx, Some(&mut self.cache), p, false, &takes), + // A click paints no region of the selection, so none is highlighted. + LpPaint::Strokes => lp_hover(cx, None, p, true, &takes), + LpPaint::Both => match lp_part_at(cx.doc, p, cx.tol(4.0), true, &takes) { + Some((_, Some(e))) => e.path_data().map(|pd| LpHover::Part(pd.to_bezpath())).unwrap_or_default(), + _ => lp_hover(cx, Some(&mut self.cache), p, false, &takes), + }, + } } } @@ -113,8 +184,7 @@ impl Tool for LivePaintBucketTool { match ev.kind { PointerKind::Move => { self.at = Some((p, ev.mods.shift, ev.mods.alt)); - self.hover = - if ev.mods.alt { LpHover::None } else { lp_hover(cx, Some(&mut self.cache), p, ev.mods.shift, &|id| lp_visible(cx, id)) }; + self.hover = self.hover_at(cx, p, ev.mods.shift, ev.mods.alt); vec![] } PointerKind::Down => { @@ -142,13 +212,14 @@ impl Tool for LivePaintBucketTool { }); } let point = json!([p.x, p.y]); - if ev.mods.shift { - return match lp_part_at(cx.doc, p, cx.tol(4.0), true, &|id| lp_visible(cx, id)) { - Some((g, Some(_))) => { - vec![Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": point, "tolerance": cx.tol(4.0) }))] - } - _ => vec![], - }; + let paint = self.paints(ev.mods.shift); + if matches!(paint, LpPaint::Strokes | LpPaint::Both) + && let Some((g, Some(_))) = lp_part_at(cx.doc, p, cx.tol(4.0), true, &|id| lp_visible(cx, id)) + { + return vec![Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": point, "tolerance": cx.tol(4.0) }))]; + } + if !matches!(paint, LpPaint::Fills | LpPaint::Both) { + return vec![]; } if let Some(g) = live_paint_at(cx.doc, p) { return vec![Action::Exec("livePaint.fill".into(), json!({ "group": g.0, "point": point }))]; @@ -164,13 +235,39 @@ impl Tool for LivePaintBucketTool { } fn overlays(&self, _cx: &ToolContext) -> Vec { - lp_overlays(&self.hover, &self.cache, HIGHLIGHT_RED, 4.0) + lp_overlays(&self.hover, &self.cache, self.color, self.width as f32) } fn cursor(&self, _cx: &ToolContext, _p: Point, m: Mods) -> Cursor { if m.alt { Cursor::Eyedropper } else { Cursor::LivePaintBucket } } + fn options(&self) -> Value { + let [r, g, b] = self.color; + json!({ + "paintFills": self.fills, + "paintStrokes": self.strokes, + "highlight": self.highlight, + "color": format!("#{r:02x}{g:02x}{b:02x}"), + "width": self.width, + }) + } + + fn set_option(&mut self, key: &str, v: &Value) { + match key { + "paintFills" => self.fills = v.as_bool().unwrap_or(self.fills), + "paintStrokes" => self.strokes = v.as_bool().unwrap_or(self.strokes), + "highlight" => self.highlight = v.as_bool().unwrap_or(self.highlight), + "color" => self.color = lp_color(v).unwrap_or(self.color), + "width" => { + let (min, max) = LP_BUCKET_WIDTH_RANGE; + self.width = v.as_f64().filter(|w| w.is_finite()).map_or(self.width, |w| w.clamp(min, max)); + } + _ => return, + } + self.hover = LpHover::None; + } + fn waiting(&self) -> bool { self.cache.waiting() } @@ -184,7 +281,7 @@ impl Tool for LivePaintBucketTool { loop { self.cache.settle(wait); if let Some((p, shift, alt)) = self.at { - self.hover = if alt { LpHover::None } else { lp_hover(cx, Some(&mut self.cache), p, shift, &|id| lp_visible(cx, id)) }; + self.hover = self.hover_at(cx, p, shift, alt); } if !wait || !self.cache.waiting() { break; diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index f4a8b771f..9bfc411bf 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -765,6 +765,106 @@ fn bucket_click_fills_face_and_shift_click_strokes_edge() { assert_eq!(id, "livePaint.strokeEdge"); } +#[test] +fn bucket_options_choose_what_a_click_paints() { + let (d, gid, ..) = live_group_doc(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Default::default() }; + let fill = |x: f64| vec![Action::Exec("livePaint.fill".into(), json!({ "group": gid.0, "point": [x, 25.0] }))]; + let stroke = |x: f64| vec![Action::Exec("livePaint.strokeEdge".into(), json!({ "group": gid.0, "point": [x, 25.0], "tolerance": 4.0 }))]; + let click = |t: &mut LivePaintBucketTool, x: f64, m: Mods| t.pointer(&cx, &ev(PointerKind::Down, x, 25.0).with_mods(m)); + let hovered = |t: &mut LivePaintBucketTool, x: f64, m: Mods| { + t.pointer(&cx, &ev(PointerKind::Move, x, 25.0).with_mods(m)); + match t.overlays(&cx).as_slice() { + [] => None, + [Overlay::Path { path, .. }] => Some(path.bounding_box()), + ov => panic!("{ov:?}"), + } + }; + let (face, edge) = (Some(Rect::new(50.0, 0.0, 100.0, 50.0)), Some(Rect::new(50.0, 0.0, 50.0, 50.0))); + let mut t = LivePaintBucketTool::default(); + assert_eq!(t.options(), json!({"paintFills": true, "paintStrokes": false, "highlight": true, "color": "#ff2a2a", "width": 4.0})); + // Paint Fills alone: 1 pt from the edge, a click fills the face and Shift-click strokes the edge. + assert_eq!((click(&mut t, 51.0, Mods::default()), hovered(&mut t, 51.0, Mods::default())), (fill(51.0), face)); + assert_eq!((click(&mut t, 51.0, shift), hovered(&mut t, 51.0, shift)), (stroke(51.0), edge)); + // Paint Strokes alone: a click strokes the edge in reach and paints nothing farther away; + // Shift-click fills the face. + t.set_option("paintFills", &json!(false)); + t.set_option("paintStrokes", &json!(true)); + assert_eq!((click(&mut t, 51.0, Mods::default()), hovered(&mut t, 51.0, Mods::default())), (stroke(51.0), edge)); + assert_eq!((click(&mut t, 75.0, Mods::default()), hovered(&mut t, 75.0, Mods::default())), (vec![], None)); + assert_eq!((click(&mut t, 51.0, shift), hovered(&mut t, 51.0, shift)), (fill(51.0), face)); + // Both: a click strokes the edge in reach, else fills the face; Shift-click fills the face. + t.set_option("paintFills", &json!(true)); + assert_eq!((click(&mut t, 51.0, Mods::default()), hovered(&mut t, 51.0, Mods::default())), (stroke(51.0), edge)); + assert_eq!((click(&mut t, 75.0, Mods::default()), hovered(&mut t, 75.0, Mods::default())), (fill(75.0), face)); + assert_eq!((click(&mut t, 51.0, shift), hovered(&mut t, 51.0, shift)), (fill(51.0), face)); + // Both off: nothing is painted or highlighted. + t.set_option("paintFills", &json!(false)); + t.set_option("paintStrokes", &json!(false)); + for m in [Mods::default(), shift] { + assert_eq!((click(&mut t, 51.0, m), hovered(&mut t, 51.0, m)), (vec![], None)); + } + // A value that is not true or false is ignored. + t.set_option("paintFills", &json!("yes")); + assert_eq!(t.options()["paintFills"], false); +} + +#[test] +fn bucket_strokes_alone_paints_and_highlights_no_region_of_the_selection() { + let (d, a, b) = two_rects(); + let mut s = Selection::default(); + s.set([a, b]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = LivePaintBucketTool::default(); + t.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)); + t.poll(&cx, true); + assert!(!t.overlays(&cx).is_empty(), "Paint Fills highlights the region"); + t.set_option("paintFills", &json!(false)); + t.set_option("paintStrokes", &json!(true)); + t.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)); + t.poll(&cx, true); + assert!(t.overlays(&cx).is_empty()); + assert!(t.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)).is_empty()); +} + +#[test] +fn bucket_options_set_the_highlight() { + let (d, ..) = live_group_doc(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = LivePaintBucketTool::default(); + let highlight = |t: &mut LivePaintBucketTool| { + t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0)); + match t.overlays(&cx).as_slice() { + [] => None, + [Overlay::Path { color, width, .. }] => Some((*color, *width)), + ov => panic!("{ov:?}"), + } + }; + assert_eq!(highlight(&mut t), Some((HIGHLIGHT_RED, 4.0))); + t.set_option("color", &json!("#00FF00")); + t.set_option("width", &json!(9)); + assert_eq!(highlight(&mut t), Some(([0, 0xff, 0], 9.0))); + t.set_option("color", &json!("light blue")); + t.set_option("width", &json!("wide")); + assert_eq!(highlight(&mut t), Some(([0x4f, 0x80, 0xff], 9.0))); + t.set_option("width", &json!(100)); + assert_eq!(t.options()["width"], json!(LP_BUCKET_WIDTH_RANGE.1)); + t.set_option("width", &json!(0)); + assert_eq!(t.options()["width"], json!(LP_BUCKET_WIDTH_RANGE.0)); + // Highlight off: nothing is highlighted, and a click still paints. + t.set_option("highlight", &json!(false)); + assert_eq!(highlight(&mut t), None); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0)).len(), 1); + t.set_option("highlight", &json!(true)); + assert!(highlight(&mut t).is_some()); +} + #[test] fn bucket_alt_samples_face_edge_and_selected_region_without_painting() { let (mut d, _gid, face, edge) = live_group_doc(); diff --git a/crates/tools/src/draw2/family.rs b/crates/tools/src/draw2/family.rs index aad4ff38f..0bbdcf79b 100644 --- a/crates/tools/src/draw2/family.rs +++ b/crates/tools/src/draw2/family.rs @@ -9,6 +9,9 @@ //! and Ctrl/Cmd keep its inner end at the distance from the center on the first modified drag as //! the outer end follows the pointer: Alt adds or removes whole winds of four segments, and //! Ctrl/Cmd changes the decay. +//! On a grid, F and V lower and raise +//! the skew of the horizontal (rectangular) or radial (polar) dividers, and X and C lower and raise +//! that of the vertical or concentric ones. use serde_json::{Value, json}; use vectorcraft_geom::Point; @@ -20,6 +23,9 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool /// How much ↑ or ↓ changes the arc's slope while it is dragged out. const ARC_SLOPE_STEP: f64 = 10.0; +/// How much F, V, X or C changes a grid's divider skew (percent) while it is dragged out. +const GRID_SKEW_STEP: f64 = 10.0; + pub struct FamilyTool { id: &'static str, start: Option, @@ -157,6 +163,25 @@ impl FamilyTool { } } + /// The skew a letter changes while a grid is dragged out, as an index into `skew`, and the + /// direction: F lowers and V raises the skew of the horizontal dividers (a rectangular grid's + /// rows) or the radial dividers (a polar grid's), X lowers and C raises that of the vertical + /// dividers (columns) or the concentric dividers. + fn grid_skew_key(&self, key: ToolKey) -> Option<(usize, f64)> { + let (horizontal_or_radial, vertical_or_concentric) = match self.id { + "rectangularGrid" => (0, 1), + "polarGrid" => (1, 0), + _ => return None, + }; + match key { + ToolKey::Letter('f') => Some((horizontal_or_radial, -1.0)), + ToolKey::Letter('v') => Some((horizontal_or_radial, 1.0)), + ToolKey::Letter('x') => Some((vertical_or_concentric, -1.0)), + ToolKey::Letter('c') => Some((vertical_or_concentric, 1.0)), + _ => None, + } + } + /// A grid's options a drag draws with: its dividers, skews and switches (the Default Size and /// Reference Point are for a click's dialog). fn grid_options(&self) -> Value { @@ -316,6 +341,14 @@ impl Tool for FamilyTool { self.columns = self.columns.saturating_sub(1); true } + ("rectangularGrid" | "polarGrid", ToolKey::Letter(_)) => match self.grid_skew_key(key) { + Some((i, sign)) => { + let max = vectorcraft_geom::shapes::GRID_SKEW_MAX; + self.skew[i] = (self.skew[i] + sign * GRID_SKEW_STEP).clamp(-max, max); + true + } + None => false, + }, _ => false, }; if changed { @@ -338,8 +371,14 @@ impl Tool for FamilyTool { } } /// The Arc tool takes C and F while an arc is dragged out: C switches it between open and closed, and F switches its base axis. + /// The grid tools take F, V, X and C while a grid is dragged out, for its divider skews. fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { - self.id == "arc" && self.began && self.start.is_some() && matches!(key, ToolKey::Letter('c' | 'f')) + self.began + && self.start.is_some() + && match self.id { + "arc" => matches!(key, ToolKey::Letter('c' | 'f')), + _ => self.grid_skew_key(key).is_some(), + } } fn overlays(&self, cx: &ToolContext) -> Vec { let mut o = self.snap.guides().to_vec(); @@ -458,6 +497,48 @@ mod tests { assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 90.0, 60.0)), vec![Action::Commit]); } + /// While a grid is dragged out, F/V lower/raise the skew of the horizontal (rectangular) or + /// radial (polar) dividers by 10 and X/C change that of the vertical or concentric ones, within + /// ±500; the grid drawn next keeps them. + #[test] + fn grid_drag_keys_change_the_divider_skews() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let key = ToolKey::Letter; + for (id, horizontal_or_radial, vertical_or_concentric) in + [("rectangularGrid", "rowSkew", "columnSkew"), ("polarGrid", "radialSkew", "concentricSkew")] + { + let mut t = FamilyTool::new(id); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + assert!(!t.claims_key(&cx, key('f')), "{id}: no drag yet"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0)); + assert!("fvxc".chars().all(|c| t.claims_key(&cx, key(c))), "{id}"); + assert!(!t.claims_key(&cx, key('e')), "{id}"); + let skews = |a: Vec| match &a[..] { + [Action::Preview(c, v)] if c == &format!("shape.{id}") => (v[horizontal_or_radial].clone(), v[vertical_or_concentric].clone()), + _ => panic!("{id}: {a:?}"), + }; + assert_eq!(skews(t.key(&cx, key('v'), Mods::default())), (json!(10.0), json!(0.0)), "{id}: V"); + assert_eq!(skews(t.key(&cx, key('f'), Mods::default())), (json!(0.0), json!(0.0)), "{id}: F"); + assert_eq!(skews(t.key(&cx, key('f'), Mods::default())), (json!(-10.0), json!(0.0)), "{id}: F"); + assert_eq!(skews(t.key(&cx, key('c'), Mods::default())), (json!(-10.0), json!(10.0)), "{id}: C"); + assert_eq!(skews(t.key(&cx, key('x'), Mods::default())), (json!(-10.0), json!(0.0)), "{id}: X"); + for _ in 0..60 { + t.key(&cx, key('x'), Mods::default()); + t.key(&cx, key('v'), Mods::default()); + } + assert_eq!(skews(t.key(&cx, key('x'), Mods::default())), (json!(500.0), json!(-500.0)), "{id}: within ±500"); + assert_eq!(skews(t.key(&cx, key('v'), Mods::default())), (json!(500.0), json!(-500.0)), "{id}: within ±500"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 60.0, 40.0)), vec![Action::Commit]); + assert!(!t.claims_key(&cx, key('v')), "{id}: the drag is over"); + let o = t.options(); + assert_eq!((o[horizontal_or_radial].clone(), o[vertical_or_concentric].clone()), (json!(500.0), json!(-500.0)), "{id}"); + } + } + /// #1377: a drag draws with the tool's stored options, which keep to their types and range. #[test] fn a_grid_drag_uses_the_stored_options() { @@ -739,8 +820,8 @@ mod tests { assert!(!t.claims_key(&cx, c), "the drag is over"); let o = t.options(); assert_eq!((o["closed"].clone(), o["baseAlong"].clone(), o["slope"].clone()), (json!(false), json!("y"), json!(100.0))); - // The other tools of the family leave C and F to their shortcuts. - let mut g = FamilyTool::new("rectangularGrid"); + // The Spiral tool leaves C and F to their shortcuts. + let mut g = FamilyTool::new("spiral"); g.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); g.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0)); assert!(!g.claims_key(&cx, c) && !g.claims_key(&cx, f)); diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index a3c3cd93e..cdddc451c 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -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, @@ -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; diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index 29fe7614f..d9bd9a365 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -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. diff --git a/crates/tools/src/guides/snap.rs b/crates/tools/src/guides/snap.rs index 4766455f8..0b117b95b 100644 --- a/crates/tools/src/guides/snap.rs +++ b/crates/tools/src/guides/snap.rs @@ -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) { diff --git a/crates/tools/src/guides/targets.rs b/crates/tools/src/guides/targets.rs index edddd1a48..36947c931 100644 --- a/crates/tools/src/guides/targets.rs +++ b/crates/tools/src/guides/targets.rs @@ -81,11 +81,11 @@ fn within(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], skip: &HashSet, 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() } diff --git a/crates/tools/src/guides/tests.rs b/crates/tools/src/guides/tests.rs index 7ecdc2f1c..e82618fb3 100644 --- a/crates/tools/src/guides/tests.rs +++ b/crates/tools/src/guides/tests.rs @@ -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. diff --git a/crates/tools/src/settings.rs b/crates/tools/src/settings.rs index 1e94c6d2c..e7b9f9a5c 100644 --- a/crates/tools/src/settings.rs +++ b/crates/tools/src/settings.rs @@ -45,6 +45,8 @@ const ROWS: &[Row] = &[ (&["blobBrush", "eraser"], None, &["angle", "roundness"]), // Live Paint Selection Tool Options (`builder::LivePaintSelectionTool`). (&["livePaintSelection"], None, &["selectFills", "selectStrokes", "color", "width"]), + // Live Paint Bucket Options (`builder::LivePaintBucketTool`). + (&["livePaintBucket"], None, &["paintFills", "paintStrokes", "highlight", "color", "width"]), (&["polygon"], None, &["sides"]), (&["star"], None, &["points"]), (&["freeTransform"], None, &["constrain"]), diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index f2f214935..acba01a74 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -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 { 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 { /// 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], p: Point, opts: HitOptions) -> Option { let mut passed: HashSet = HashSet::new(); @@ -150,6 +153,15 @@ fn color_under(s: &mut Sampler, doc: &Document, levels: &mut [Option], }; 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], n: &Node, p: Point) -> Option { + 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())); + } } diff --git a/crates/ui-egui/src/addons/document.rs b/crates/ui-egui/src/addons/document.rs index 47ea442f4..14723e417 100644 --- a/crates/ui-egui/src/addons/document.rs +++ b/crates/ui-egui/src/addons/document.rs @@ -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 { 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 Result { + 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; diff --git a/crates/ui-egui/src/addons/document/tests.rs b/crates/ui-egui/src/addons/document/tests.rs index 5221c8414..4a2f96bd2 100644 --- a/crates/ui-egui/src/addons/document/tests.rs +++ b/crates/ui-egui/src/addons/document/tests.rs @@ -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); +} diff --git a/crates/ui-egui/src/addons/pathtools.rs b/crates/ui-egui/src/addons/pathtools.rs index 9503a35e4..2e5306677 100644 --- a/crates/ui-egui/src/addons/pathtools.rs +++ b/crates/ui-egui/src/addons/pathtools.rs @@ -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::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!({})), ] } diff --git a/crates/ui-egui/src/addons/pathtools/tests.rs b/crates/ui-egui/src/addons/pathtools/tests.rs index e69c93042..d477fa036 100644 --- a/crates/ui-egui/src/addons/pathtools/tests.rs +++ b/crates/ui-egui/src/addons/pathtools/tests.rs @@ -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…")); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b0210026a..0e9f7b75f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -56,6 +56,7 @@ pub mod justification; pub mod layer_options; pub mod layers_panel_options; pub mod liquify; +pub mod live_paint_bucket; pub mod live_paint_gaps; pub mod live_paint_selection; pub mod missing_fonts; @@ -389,6 +390,7 @@ registry! { BlendOptions: [blend_options::KIND] => blend_options::SPEC, LivePaintGaps: [live_paint_gaps::KIND] => live_paint_gaps::SPEC, LivePaintSelectionOptions: [live_paint_selection::KIND] => live_paint_selection::SPEC, + LivePaintBucketOptions: [live_paint_bucket::KIND] => live_paint_bucket::SPEC, BrushOptions: [brush_options::KIND] => brush_options::SPEC, NewBrush: [new_brush::KIND] => new_brush::SPEC, EditSelection: [edit_selection::KIND] => edit_selection::SPEC, diff --git a/crates/ui-egui/src/dialogs/char_style_options.rs b/crates/ui-egui/src/dialogs/char_style_options.rs index 848151ac5..71d48056b 100644 --- a/crates/ui-egui/src/dialogs/char_style_options.rs +++ b/crates/ui-egui/src/dialogs/char_style_options.rs @@ -19,9 +19,12 @@ const BASIC: &[&str] = &[ "position", "underline", "strikethrough", + "standardVerticalRoman", "charAlign", ]; const NUMBERS: &[&str] = &["size", "leading", "kerning", "tracking"]; +/// The on, off or unset checkboxes. +const SWITCHES: &[&str] = &["underline", "strikethrough", "standardVerticalRoman"]; pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Character Style Options").into(), @@ -41,6 +44,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { "position", "underline", "strikethrough", + "standardVerticalRoman", "charAlign", "section", ] @@ -68,9 +72,11 @@ pub fn open(app: &mut VectorcraftApp, name: &str) -> Result { pub(crate) fn fields_from_attrs(attrs: &Map) -> Map { let mut fields = Map::new(); - for key in ["font_family", "font_style", "size", "leading", "kerning", "tracking", "underline", "strikethrough", "charAlign"] { + for key in + ["font_family", "font_style", "size", "leading", "kerning", "tracking", "underline", "strikethrough", "standardVerticalRoman", "charAlign"] + { let value = attrs.get(key).cloned().unwrap_or(Value::Null); - let shown = if ["underline", "strikethrough"].contains(&key) { + let shown = if SWITCHES.contains(&key) { value } else if value.is_null() { if attrs.contains_key(key) && ["leading", "kerning"].contains(&key) { json!("Auto") } else { json!("") } @@ -144,9 +150,9 @@ pub(crate) fn edited_attrs(d: &Dialog) -> Result, String> { attrs.insert((*key).into(), v); } } - for key in ["underline", "strikethrough"] { - if let Some(v) = d.fields.get(key).and_then(Value::as_bool) { - attrs.insert(key.into(), json!(v)); + for key in SWITCHES { + if let Some(v) = d.fields.get(*key).and_then(Value::as_bool) { + attrs.insert((*key).into(), json!(v)); } } if reset || d.fields.get("case").and_then(Value::as_str) != case_from_attrs(&original) { @@ -204,8 +210,20 @@ pub(crate) fn edited_attrs(d: &Dialog) -> Result, String> { pub(crate) fn reset_basic(d: &mut Dialog) { d.fields.insert("__resetBasic".into(), json!(true)); - for key in ["font_family", "font_style", "size", "leading", "kerning", "tracking", "case", "position", "underline", "strikethrough", "charAlign"] - { + for key in [ + "font_family", + "font_style", + "size", + "leading", + "kerning", + "tracking", + "case", + "position", + "underline", + "strikethrough", + "standardVerticalRoman", + "charAlign", + ] { d.fields.insert( key.into(), if ["font_family", "font_style", "size", "leading", "kerning", "tracking"].contains(&key) { json!("") } else { Value::Null }, @@ -255,7 +273,11 @@ pub(crate) fn basic_body(ui: &mut egui::Ui, d: &mut Dialog) { ui.label(tl!("Position")); choice(ui, d, "position", &[("normal", tl!("Normal")), ("superscript", tl!("Superscript")), ("subscript", tl!("Subscript"))]); ui.end_row(); - for (key, caption) in [("underline", tl!("Underline")), ("strikethrough", tl!("Strikethrough"))] { + for (key, caption) in [ + ("underline", tl!("Underline")), + ("strikethrough", tl!("Strikethrough")), + ("standardVerticalRoman", tl!("Standard Vertical Roman Alignment")), + ] { ui.label(""); let value = d.fields.get(key).and_then(Value::as_bool); if crate::widgets::check3(ui, caption, value, true) { @@ -270,7 +292,7 @@ pub(crate) fn basic_body(ui: &mut egui::Ui, d: &mut Dialog) { } ui.end_row(); } - ui.label(tl!("Standard Vertical Roman Alignment")); + ui.label(tl!("Character Alignment")); choice( ui, d, @@ -466,6 +488,39 @@ mod tests { assert_eq!(app.session.doc().unwrap().doc.char_styles.iter().find(|def| def.name == "Future").unwrap().attrs["size"], json!(36.0)); } + /// Basic Character Formats shows Character Alignment under its own name, and Standard Vertical + /// Roman Alignment as its own on, off or unset checkbox that the style saves. + #[test] + fn standard_vertical_roman_alignment_has_its_own_row() { + let mut d = Dialog::new(KIND, json!({})); + d.fields.extend(fields_from_attrs(&Map::new())); + d.fields.insert("__attrs".into(), json!({})); + let mut app = app(); + let shown = crate::tests_labels::painted_text(&mut app, |_, ui| basic_body(ui, &mut d)); + assert!(shown.contains("Character Alignment"), "{shown}"); + assert!(shown.contains("Standard Vertical Roman Alignment"), "{shown}"); + assert!(d.fields["standardVerticalRoman"].is_null()); + assert_eq!(edited_attrs(&d).unwrap(), Map::new()); + d.fields.insert("standardVerticalRoman".into(), json!(true)); + d.fields.insert("charAlign".into(), json!("emBoxCenter")); + assert_eq!(edited_attrs(&d).unwrap(), json!({"standardVerticalRoman": true, "charAlign": "emBoxCenter"}).as_object().unwrap().clone()); + let fields = fields_from_attrs(json!({"standardVerticalRoman": false}).as_object().unwrap()); + assert_eq!(fields["standardVerticalRoman"], json!(false)); + reset_basic(&mut d); + assert!(d.fields["standardVerticalRoman"].is_null()); + + app.run("charStyle.new", json!({"name": "Upright", "attrs": {}})).unwrap(); + let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "AB", "vertical": true})).unwrap()["id"].as_u64().unwrap(); + app.run("charStyle.apply", json!({"name": "Upright", "id": id})).unwrap(); + open(&mut app, "Upright").unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("standardVerticalRoman".into(), json!(true)); + super::super::confirm(&mut app).unwrap(); + let doc = &app.session.doc().unwrap().doc; + assert_eq!(doc.char_styles[0].attrs["standardVerticalRoman"], json!(true)); + let vectorcraft_doc::NodeKind::Text(t) = &doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind else { panic!("text") }; + assert!(t.runs.iter().all(|r| r.style.standard_vertical_roman)); + } + #[test] fn invalid_numbers_name_the_visible_field() { let mut d = Dialog::new(KIND, json!({})); diff --git a/crates/ui-egui/src/dialogs/live_paint_bucket.rs b/crates/ui-egui/src/dialogs/live_paint_bucket.rs new file mode 100644 index 000000000..550b23534 --- /dev/null +++ b/crates/ui-egui/src/dialogs/live_paint_bucket.rs @@ -0,0 +1,151 @@ +//! Live Paint Bucket Options (double-click the Live Paint Bucket tool): what a click with the tool +//! paints and how the face or edge under the pointer highlights. The options last across tool +//! switches and are saved with the preferences. OK sets them with +//! `tool.setOption {tool: "livePaintBucket", values}`. +//! +//! Fields: `paintFills` and `paintStrokes` (the faces and the edges of Live Paint groups), +//! `highlight` (whether the face or edge under the pointer is outlined), `color` (the highlight +//! color: `#rrggbb`, or a layer color's name such as "Red") and `width` (the highlight's width in +//! screen pixels). + +use serde_json::{Map, Value, json}; +use vectorcraft_color::Color; +use vectorcraft_doc::LAYER_COLORS; +use vectorcraft_tools::builder::LP_BUCKET_WIDTH_RANGE; + +use super::tile_edge_color::resolve; +use super::{DialogSpec, form, run_and_close}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of the Live Paint Bucket Options. +pub const KIND: &str = "livePaintBucketOptions"; + +/// The tool the dialog serves. +const TOOL: &str = "livePaintBucket"; + +/// The options the dialog edits. +const KEYS: [&str; 5] = ["paintFills", "paintStrokes", "highlight", "color", "width"]; + +/// Width of the label column. +const LABEL_W: f32 = 60.0; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 300.0, ..DialogSpec::FORM }; + +fn heading(_: &Dialog) -> String { + let label = vectorcraft_tools::tool_info(TOOL).map_or("", |t| t.label); + crate::i18n::fmt(tl!("{tool} Options"), &[("tool", tl!(label))]) +} + +/// Open the Live Paint Bucket Options with the tool's current values. +pub fn open(app: &mut VectorcraftApp) -> Result { + let o = app.session.tool_options_of(TOOL); + let fields: Map = KEYS.iter().filter_map(|key| Some((key.to_string(), o.get(*key)?.clone()))).collect(); + app.ui.dialog = Some(Dialog { kind: KIND.into(), fields }); + Ok(json!({ "dialog": KIND })) +} + +fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + form::check(ui, d, "paintFills", tl!("Paint Fills")); + form::check(ui, d, "paintStrokes", tl!("Paint Strokes")); + ui.add_space(6.0); + form::check(ui, d, "highlight", tl!("Highlight")); + let color = resolve(&d.str("color")).unwrap_or(Color::BLACK); + let [r, g, b, _] = color.to_rgba8(1.0); + let preset = LAYER_COLORS.iter().position(|(_, c)| *c == [r, g, b]); + let names: Vec<&str> = LAYER_COLORS.iter().map(|(n, _)| *n).chain(["Custom"]).collect(); + ui.add_enabled_ui(d.bool("highlight"), |ui| { + widgets::label_row(ui, tl!("Color:"), LABEL_W, |ui| { + if let Some((_, [r, g, b])) = + widgets::dropdown(ui, "lp-bucket-color", names[preset.unwrap_or(LAYER_COLORS.len())], &names, 130.0).and_then(|i| LAYER_COLORS.get(i)) + { + d.fields.insert("color".into(), json!(Color::rgb8(*r, *g, *b).to_hex())); + } + // The color button sets any other color; the dropdown then shows Custom. + let mut rgb = [r, g, b]; + if ui.color_edit_button_srgb(&mut rgb).changed() { + d.fields.insert("color".into(), json!(Color::rgb8(rgb[0], rgb[1], rgb[2]).to_hex())); + } + }); + let (min, max) = LP_BUCKET_WIDTH_RANGE; + let v = d.f64("width", min); + widgets::label_row(ui, tl!("Width:"), LABEL_W, |ui| { + if let Some(x) = widgets::range_field(ui, "lp-bucket-width", v, min..=max, "px", 0, 72.0) { + d.fields.insert("width".into(), json!(x)); + } + }); + }); + false +} + +/// OK: set the tool's options. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let values: Map = KEYS.iter().filter_map(|k| Some((k.to_string(), d.fields.get(*k)?.clone()))).collect(); + run_and_close(app, "tool.setOption", json!({ "tool": TOOL, "values": values })) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + use vectorcraft_tools::{Overlay, PointerEvent, PointerKind}; + + /// The labels the open dialog paints. + fn painted(app: &mut VectorcraftApp) -> String { + crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx())) + } + + #[test] + fn double_clicking_the_tool_opens_its_options_and_ok_sets_them() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + // Two squares side by side, made a Live Paint group: two faces and the edge between them. + for x in [0, 100] { + app.run("shape.rectangle", json!({"x": 50 + x, "y": 50, "width": 100, "height": 100})).unwrap(); + } + app.run("select.all", json!({})).unwrap(); + app.run("livePaint.make", json!({})).unwrap(); + app.run("select.set", json!({"ids": []})).unwrap(); + app.select_tool(TOOL); + let hover = |app: &mut VectorcraftApp, x: f64| { + let view = app.view_info(); + crate::canvas::dispatch(app, &PointerEvent::new(PointerKind::Move, x, 100.0), view); + app.session.overlays(view) + }; + let ov = hover(&mut app, 151.0); + assert!(ov.iter().any(|o| matches!(o, Overlay::Path { color: [0xff, 0x2a, 0x2a], width: 4.0, .. })), "{ov:?}"); + + crate::toolbar::open_options(&mut app, TOOL).unwrap(); + let text = painted(&mut app); + for label in ["Live Paint Bucket Options", "Paint Fills", "Paint Strokes", "Highlight", "Color:", "Width:"] { + assert!(text.contains(label), "{label} in {text}"); + } + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!(d.kind, KIND); + assert_eq!( + (d.bool("paintFills"), d.bool("paintStrokes"), d.bool("highlight"), d.str("color"), d.f64("width", 0.0)), + (true, false, true, "#ff2a2a".to_string(), 4.0) + ); + d.fields.insert("paintFills".into(), json!(false)); + d.fields.insert("paintStrokes".into(), json!(true)); + d.fields.insert("color".into(), json!("Green")); + d.fields.insert("width".into(), json!(8)); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + // The tool takes them: by the edge, the edge highlights in the new color and width. + let ov = hover(&mut app, 151.0); + assert!(ov.iter().any(|o| matches!(o, Overlay::Path { color: [0x4f, 0xff, 0x4f], width: 8.0, .. })), "{ov:?}"); + // They last while another tool is chosen, and the dialog opens on them again. + app.select_tool("selection"); + let o = app.session.tool_options_of(TOOL); + assert_eq!((o["paintFills"].as_bool(), o["paintStrokes"].as_bool(), o["color"].as_str()), (Some(false), Some(true), Some("#4fff4f"))); + app.run("tool.options", json!({"tool": TOOL})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!((d.bool("paintStrokes"), d.str("color")), (true, "#4fff4f".to_string())); + // Highlight off: the tool outlines nothing under the pointer. + d.fields.insert("highlight".into(), json!(false)); + super::super::confirm(&mut app).unwrap(); + app.select_tool(TOOL); + assert!(!hover(&mut app, 151.0).iter().any(|o| matches!(o, Overlay::Path { .. }))); + } +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 3a426bdf0..d35434d0b 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1234,6 +1234,9 @@ font-technology Variable Променливи Hide Live Paint Gaps Скриване на пролуките на динамичното оцветяване Select Fills Избиране на запълвания Select Strokes Избиране на щрихове + Paint Fills Боядисване на запълвания + Paint Strokes Боядисване на щрихове + Highlight Маркиране Gaussian Blur… Гаусово размазване… General Общи General › Placement › X Общи › Разположение › X @@ -5936,6 +5939,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}` @@ -5966,6 +5972,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 Запазване на копие с дата и час @@ -5988,6 +5997,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 Динамичен символ diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index ade36fb98..e9a90846d 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -267,6 +267,9 @@ Hide Live Paint Gaps Sakrij praznine živog bojenja Select Fills Odaberi ispune Select Strokes Odaberi poteze + Paint Fills Boji ispune + Paint Strokes Boji poteze + Highlight Isticanje Image Trace Praćenje slike Image Trace (Make and Expand) Praćenje slike (napravi i proširi) Release Image Trace Oslobodi praćenje slike @@ -5969,6 +5972,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}` @@ -5999,6 +6004,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 @@ -6021,6 +6029,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 diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index f7e63331d..83276e9fa 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1863,6 +1863,7 @@ digits Malayalam Malaiàlam High Quality Print Impressió d’alta qualitat High Resolution Alta resolució High resolution Alta resolució + Highlight Ressaltat Highlight Alternate Glyphs Ressalta els glifs alternatius Highlight anchors on mouse over Ressalta els punts d’ancoratge en passar el ratolí Highlight Area Ressalta l’àrea @@ -2848,8 +2849,10 @@ digits Malayalam Malaiàlam Pages Pàgines Paint Daubs Taques de pintura Paint Daubs… Taques de pintura… + Paint Fills Pinta els farciments Paint Opacity: Opacitat de pintura: Paint Stops At: La pintura s’atura a: + Paint Strokes Pinta els traços Paintbrush Pinzell Paintbrush Tool Eina de pinzell Palette Knife Espàtula @@ -6041,6 +6044,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. @@ -6073,6 +6079,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 @@ -6095,6 +6104,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 diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7de8e9d83..4937e045e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -277,6 +277,9 @@ Hide Live Paint Gaps Skrýt mezery živé malby Select Fills Vybírat výplně Select Strokes Vybírat tahy + Paint Fills Malovat výplně + Paint Strokes Malovat tahy + Highlight Zvýraznění Image Trace Vektorizace obrazu Image Trace (Make and Expand) Vektorizace obrazu (vytvořit a rozložit) Release Image Trace Uvolnit vektorizaci obrazu @@ -6053,6 +6056,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 @@ -6083,6 +6089,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 @@ -6105,6 +6114,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 diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 0c65036b7..13bc90584 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5316,6 +5316,9 @@ swatch Bright Violet Kraftig violet Close Gaps With Paths Luk huller med stier Select Fills Markér udfyldninger Select Strokes Markér konturer + Paint Fills Mal udfyldninger + Paint Strokes Mal konturer + Highlight Fremhævning Column Type: Søjletype: Each Design Represents: Hvert design repræsenterer: Event: Hændelse: @@ -5951,6 +5954,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}` @@ -5981,6 +5987,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 @@ -6003,6 +6012,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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index da82ec53c..39bea80cf 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1258,6 +1258,9 @@ file-filter Templates Vorlagen Hide Live Paint Gaps Lücken beim interaktiven Malen ausblenden Select Fills Flächen auswählen Select Strokes Konturen auswählen + Paint Fills Flächen malen + Paint Strokes Konturen malen + Highlight Hervorheben Gaussian Blur… Gaußscher Weichzeichner… General Allgemein General › Placement › X Allgemein › Platzierung › X @@ -5638,6 +5641,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}` @@ -5668,6 +5674,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 @@ -5690,6 +5699,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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index a1c3b486c..e74d28ee3 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1200,6 +1200,9 @@ font-technology Variable Μεταβλητές Hide Live Paint Gaps Απόκρυψη διακένων δυναμικής βαφής Select Fills Επιλογή γεμισμάτων Select Strokes Επιλογή περιγραμμάτων + Paint Fills Βαφή γεμισμάτων + Paint Strokes Βαφή περιγραμμάτων + Highlight Επισήμανση Gaussian Blur… Θόλωση Γκάους… General Γενικά General › Placement › X Γενικά › Τοποθέτηση › X @@ -6005,6 +6008,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}` @@ -6035,6 +6041,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 Αποθήκευση αντιγράφου με ημερομηνία και ώρα @@ -6057,6 +6066,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 Δυναμικό σύμβολο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9c13403af..cc65ba142 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1271,6 +1271,9 @@ font-technology Variable Variables Hide Live Paint Gaps Ocultar huecos de pintura interactiva Select Fills Seleccionar rellenos Select Strokes Seleccionar trazos + Paint Fills Pintar rellenos + Paint Strokes Pintar trazos + Highlight Resaltado Gaussian Blur… Desenfoque gaussiano… General General General › Placement › X General › Colocación › X @@ -5995,6 +5998,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}` @@ -6025,6 +6031,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 @@ -6047,6 +6056,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 diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index ec0e23d1f..f9d57a6d6 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -284,6 +284,9 @@ Hide Live Paint Gaps Piilota elävän maalauksen aukot Select Fills Valitse täytöt Select Strokes Valitse viivat + Paint Fills Maalaa täytöt + Paint Strokes Maalaa viivat + Highlight Korostus Image Trace Kuvan jäljitys Image Trace (Make and Expand) Kuvan jäljitys (luo ja laajenna) Release Image Trace Vapauta kuvan jäljitys @@ -6083,6 +6086,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 @@ -6113,6 +6119,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 @@ -6135,6 +6144,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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c17cb8c3a..26f241407 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1271,6 +1271,9 @@ font-technology Variable Variables Hide Live Paint Gaps Masquer les espaces de peinture dynamique Select Fills Sélectionner les fonds Select Strokes Sélectionner les contours + Paint Fills Peindre les fonds + Paint Strokes Peindre les contours + Highlight Mise en évidence Gaussian Blur… Flou gaussien… General Général General › Placement › X Général › Placement › X @@ -6050,6 +6053,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}` @@ -6080,6 +6086,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 @@ -6102,6 +6111,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 diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 4b330cbb3..5d33a3ab5 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1588,6 +1588,9 @@ file-filter Templates Sablonok Hide Live Paint Gaps Élő festés hézagainak elrejtése Select Fills Kitöltések kijelölése Select Strokes Körvonalak kijelölése + Paint Fills Kitöltések festése + Paint Strokes Körvonalak festése + Highlight Kiemelés Gaussian Blur Gauss-elmosás Gaussian Blur… Gauss-elmosás… General Általános @@ -6039,6 +6042,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 @@ -6069,6 +6075,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 @@ -6091,6 +6100,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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 7f03f409a..710fda56f 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5316,6 +5316,9 @@ swatch Bright Violet Violet cerah Close Gaps With Paths Tutup celah dengan jalur Select Fills Pilih isian Select Strokes Pilih stroke + Paint Fills Cat isian + Paint Strokes Cat stroke + Highlight Sorotan Column Type: Jenis kolom: Each Design Represents: Setiap desain mewakili: Event: Peristiwa: @@ -5951,6 +5954,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}` @@ -5981,6 +5987,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 @@ -6003,6 +6012,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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6ab73ae63..0a020b253 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1281,6 +1281,9 @@ file-filter Templates Modelli Hide Live Paint Gaps Nascondi spazi vuoti pittura dinamica Select Fills Seleziona riempimenti Select Strokes Seleziona tracce + Paint Fills Colora riempimenti + Paint Strokes Colora tracce + Highlight Evidenziazione Gaussian Blur… Sfocatura gaussiana… General Generale General › Placement › X Generale › Posizionamento › X @@ -5672,6 +5675,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}` @@ -5702,6 +5708,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 @@ -5724,6 +5733,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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index bb6101bfa..a363b62f0 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -292,6 +292,9 @@ digits Malayalam マラヤーラム数字 Hide Live Paint Gaps ライブペイントの間隔を隠す Select Fills 塗りを選択 Select Strokes 線を選択 + Paint Fills 塗りをペイント + Paint Strokes 線をペイント + Highlight ハイライト Image Trace 画像トレース Image Tracing 画像トレースオブジェクト Tracing Presets トレースプリセット @@ -5739,6 +5742,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}` というパターンはありません @@ -5769,6 +5775,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 日時付きのコピーを保存 @@ -5791,6 +5800,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 ダイナミックシンボル diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index eaf8b9467..d118223c6 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5344,6 +5344,9 @@ imageTrace Outline კონტური Scale in Perspective პერსპექტივაში მასშტაბირება Select Fills შევსებების მონიშვნა Select Strokes კონტურების მონიშვნა + Paint Fills შევსებების შეღებვა + Paint Strokes კონტურების შეღებვა + Highlight გამოკვეთა Set History Limit… ისტორიის ლიმიტის დაყენება… Shape Builder Delete ფიგურების კონსტრუქტორით წაშლა Sliding სრიალა @@ -6042,6 +6045,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}` არ არსებობს @@ -6072,6 +6078,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 ასლის შენახვა თარიღითა და დროით @@ -6094,6 +6103,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 დინამიკური სიმბოლო diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 15b48dc58..fbeb650e2 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -285,6 +285,9 @@ digits Malayalam 말라얄람어 Hide Live Paint Gaps 라이브 페인트 간격 숨기기 Select Fills 칠 선택 Select Strokes 선 선택 + Paint Fills 칠 페인트 + Paint Strokes 선 페인트 + Highlight 강조 표시 Image Trace 이미지 추적 Image Tracing 이미지 추적 Tracing Presets 추적 프리셋 @@ -5635,6 +5638,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}` 이름의 패턴이 없습니다 @@ -5665,6 +5671,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 날짜와 시간이 붙은 사본 저장 @@ -5687,6 +5696,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 동적 심볼 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 5408cf36e..9686ccde8 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5327,6 +5327,9 @@ swatch Bright Violet Felviolet Close Gaps With Paths Openingen sluiten met paden Select Fills Vullingen selecteren Select Strokes Lijnen selecteren + Paint Fills Vullingen verven + Paint Strokes Lijnen verven + Highlight Markering Column Type: Kolomtype: Each Design Represents: Elk ontwerp staat voor: Event: Gebeurtenis: @@ -5995,6 +5998,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}` @@ -6025,6 +6031,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 @@ -6047,6 +6056,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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 77ddf7ce2..c4457044e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -242,6 +242,9 @@ Hide Live Paint Gaps Ukryj przerwy aktywnego malowania Select Fills Zaznaczaj wypełnienia Select Strokes Zaznaczaj obrysy + Paint Fills Maluj wypełnienia + Paint Strokes Maluj obrysy + Highlight Podświetlenie Image Trace Obrys obrazu Text Wrap Oblewanie tekstem Text Wrap Options… Opcje oblewania tekstem… @@ -5908,6 +5911,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}` @@ -5938,6 +5944,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ą @@ -5960,6 +5969,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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index d029caf2f..7387d752b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1120,6 +1120,9 @@ Hide Live Paint Gaps Ocultar Lacunas da Pintura ao Vivo Select Fills Selecionar Preenchimentos Select Strokes Selecionar Traçados + Paint Fills Pintar Preenchimentos + Paint Strokes Pintar Traçados + Highlight Destaque Gaussian Blur… Desfoque Gaussiano… General Geral Generate CSS Gerar CSS @@ -5972,6 +5975,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}` @@ -6002,6 +6008,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 @@ -6024,6 +6033,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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 890d3783c..2ae902f3d 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1279,6 +1279,9 @@ file-filter Templates Шаблоны Hide Live Paint Gaps Скрыть зазоры быстрой заливки Select Fills Выделять заливки Select Strokes Выделять обводки + Paint Fills Рисовать заливки + Paint Strokes Рисовать обводки + Highlight Подсветка Gaussian Blur… Размытие по Гауссу… General Общие General › Placement › X Общие › Размещение › X @@ -6030,6 +6033,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}` не найден @@ -6060,6 +6066,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 Сохранить копию с датой и временем @@ -6082,6 +6091,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 Динамический символ diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 44c8156f7..a066cf000 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1236,6 +1236,9 @@ font-technology Variable Variabla Hide Live Paint Gaps Dölj mellanrum i direktmålning Select Fills Markera fyllningar Select Strokes Markera konturer + Paint Fills Måla fyllningar + Paint Strokes Måla konturer + Highlight Framhävning Gaussian Blur… Gaussisk oskärpa… General Allmänt General › Placement › X Allmänt › Placering › X @@ -6105,6 +6108,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}` @@ -6135,6 +6141,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 @@ -6157,6 +6166,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 diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 1c009222c..c7da5c25e 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5369,6 +5369,9 @@ theme Appearance Görünüm Overlap Örtüşme Select Fills Dolguları Seç Select Strokes Konturları Seç + Paint Fills Dolguları Boya + Paint Strokes Konturları Boya + Highlight Vurgulama Snap to Unit Birim Değerine Yasla The plug-in writes the whole document. Eklenti tüm belgeyi yazar. Attach İliştir @@ -6010,6 +6013,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 @@ -6040,6 +6046,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 @@ -6062,6 +6071,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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c5237efe8..d65a87c78 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1275,6 +1275,9 @@ file-filter Templates Шаблони Hide Live Paint Gaps Приховати проміжки швидкої заливки Select Fills Виділяти заливки Select Strokes Виділяти обведення + Paint Fills Фарбувати заливки + Paint Strokes Фарбувати обведення + Highlight Підсвічування Gaussian Blur… Розмиття за Гауссом… General Загальні General › Placement › X Загальні › Розміщення › X @@ -5652,6 +5655,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}` немає @@ -5682,6 +5688,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 Зберегти копію з датою і часом @@ -5704,6 +5713,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 Динамічний символ diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 69cb43940..b315676ff 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1490,6 +1490,9 @@ file-filter Templates Mẫu Hide Live Paint Gaps Ẩn khoảng cách tô trực tiếp Select Fills Chọn vùng tô Select Strokes Chọn nét + Paint Fills Tô vùng tô + Paint Strokes Tô nét + Highlight Làm nổi bật Gaussian Blur Mờ Gaussian Gaussian Blur… Làm mờ Gaussian… General Chung @@ -5655,6 +5658,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}` @@ -5685,6 +5691,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ờ @@ -5707,6 +5716,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 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 10199d10a..0e1bb22ec 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1219,6 +1219,9 @@ file-filter Templates 模板 Hide Live Paint Gaps 隐藏实时上色间隙 Select Fills 选择填色 Select Strokes 选择描边 + Paint Fills 为填色上色 + Paint Strokes 为描边上色 + Highlight 突出显示 Gaussian Blur… 高斯模糊… General 常规 General › Placement › X 一般 › 位置 › X @@ -5895,6 +5898,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}` 的图案 @@ -5925,6 +5931,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 保存带时间戳的副本 @@ -5947,6 +5956,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 动态符号 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b81502f53..33d3510c8 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1245,6 +1245,9 @@ file-filter Templates 範本 Hide Live Paint Gaps 隱藏即時上色間隙 Select Fills 選取填色 Select Strokes 選取筆畫 + Paint Fills 為填色上色 + Paint Strokes 為筆畫上色 + Highlight 強調顯示 Gaussian Blur… 高斯模糊… General 一般 General › Placement › X 一般 › 位置 › X @@ -5692,6 +5695,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}` 的圖樣 @@ -5722,6 +5728,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 儲存附時間戳記的拷貝 @@ -5744,6 +5753,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 動態符號 diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 5da061cbe..e7f80ecd8 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -531,6 +531,7 @@ fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option 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), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 28642f015..11f342420 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -691,7 +691,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "tool.options", "Tool Options…", "", - "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x, y and preview (on initially), previews one there and accepts it as one Undo step; Cancel rolls back the preview), lineSegment|arc|rectangularGrid|polarGrid → that tool's Tool Options, its click dialog without x and y (dialog kind = the tool id, `__toolOptions: true`; lineSegment: length (pt), angle (°), fillLine; arc: lengthX, lengthY (pt), reference (0–8), arcType: open|closed, baseAlong: x|y, slope (−100…100), fillArc; rectangularGrid: width, height (pt, the Default Size), reference (0–8), rows, columns, rowSkew, columnSkew (%), frame, fill; polarGrid: the same with concentric, radial, concentricSkew, radialSkew and compound for rows, columns, the skews and frame; OK runs tool.setOption {tool, values} and draws nothing: the next drag uses them (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and a click's dialog opens at them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), typeOnPath|verticalTypeOnPath → Type on a Path Options for the selected type on a path (dialog `command` running type.pathOptions; error `nothing selected` without a selection, and the command's error when no type on a path is selected),symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (pt), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), livePreview (pencil, on: off draws a thin line while the stroke is drawn, and the path's fill and stroke on release), size (pt), angle (°, −180–180; 0) and roundness (%, 1–100; 100) of the eraser's tip; OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values}), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \"shapeBuilder\", values}), objectsOnPath → Objects on Path Tool Options (dialog `objectsOnPathOptions`, fields pivot (0–8, row by row from the top left; 4, the center) and rotate (°, −360–360; 0); Reset puts 4 and 0 back; Save runs tool.setOption {tool: \"objectsOnPath\", values}: the objects attached next use them, groups attached before keep theirs)", + "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x, y and preview (on initially), previews one there and accepts it as one Undo step; Cancel rolls back the preview), lineSegment|arc|rectangularGrid|polarGrid → that tool's Tool Options, its click dialog without x and y (dialog kind = the tool id, `__toolOptions: true`; lineSegment: length (pt), angle (°), fillLine; arc: lengthX, lengthY (pt), reference (0–8), arcType: open|closed, baseAlong: x|y, slope (−100…100), fillArc; rectangularGrid: width, height (pt, the Default Size), reference (0–8), rows, columns, rowSkew, columnSkew (%), frame, fill; polarGrid: the same with concentric, radial, concentricSkew, radialSkew and compound for rows, columns, the skews and frame; OK runs tool.setOption {tool, values} and draws nothing: the next drag uses them (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and a click's dialog opens at them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), typeOnPath|verticalTypeOnPath → Type on a Path Options for the selected type on a path (dialog `command` running type.pathOptions; error `nothing selected` without a selection, and the command's error when no type on a path is selected),symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (pt), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), livePreview (pencil, on: off draws a thin line while the stroke is drawn, and the path's fill and stroke on release), size (pt), angle (°, −180–180; 0) and roundness (%, 1–100; 100) of the eraser's tip; OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values}), livePaintBucket → Live Paint Bucket Options (dialog `livePaintBucketOptions`, fields paintFills (on) and paintStrokes (off), highlight (on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintBucket\", values}), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \"shapeBuilder\", values}), objectsOnPath → Objects on Path Tool Options (dialog `objectsOnPathOptions`, fields pivot (0–8, row by row from the top left; 4, the center) and rotate (°, −360–360; 0); Reset puts 4 and 0 back; Save runs tool.setOption {tool: \"objectsOnPath\", values}: the objects attached next use them, groups attached before keep theirs)", ), ( "ui.colorGuideOptions", diff --git a/crates/ui-egui/src/panels/artboards.rs b/crates/ui-egui/src/panels/artboards.rs index 695f63fd6..77023bacb 100644 --- a/crates/ui-egui/src/panels/artboards.rs +++ b/crates/ui-egui/src/panels/artboards.rs @@ -1,6 +1,6 @@ //! Artboards panel: numbered list with inline rename (double-click the name, or the panel menu's //! Rename; while several artboards are selected, the name typed goes to all of them, numbered), -//! move up / down, new +//! move up / down (or drag a row above or below another), new //! (Alt-click: Artboard Options for the new artboard first), delete; a row dragged onto New //! Artboard duplicates its artboard (as Duplicate Artboards does, with //! its art when the Artboard tool's Move/Copy Artwork with Artboard is on). The highlighted row is the active artboard, the one the status bar's navigator shows and @@ -405,6 +405,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let all = picked(app, abs.len()); let editing: Option = pstate(ui.ctx(), "ab-edit"); let mut row_clicked = false; + // A row dropped on another row: (from, to), the index it lands at. + let mut dropped = None; let list_rect = widgets::list_box(ui, |ui| { ui.set_min_height(110.0); ui.spacing_mut().item_spacing.y = 0.0; @@ -512,10 +514,26 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } else if resp.double_clicked() { start_rename(ui.ctx(), app, i); } + // Another row dragged over this one lands above or below it by the pointer's half, + // a line showing where. + if let Some(from) = resp.dnd_hover_payload::().map(|d| d.0).filter(|from| *from != i) { + let after = ui.input(|inp| inp.pointer.interact_pos()).is_some_and(|p| p.y > r.center().y); + let y = if after { r.bottom() } else { r.top() }; + ui.painter().line_segment([pos2(r.left(), y), pos2(r.right(), y)], egui::Stroke::new(2.0, t.accent)); + if resp.dnd_release_payload::().is_some() { + let to = i + usize::from(after); + dropped = Some((from, if from < to { to - 1 } else { to })); + } + } } }); scrolled.inner_rect }); + if let Some((from, to)) = dropped.filter(|(from, to)| from != to) + && app.run("artboard.reorder", json!({"index": from, "to": to})).is_ok() + { + select(app, to); + } // Keyboard: the panel takes it with a click on a row and gives it up when the pointer is // pressed outside the list or the panel is not drawn (`ab-keys`: the frame it last had it in). let pressed_outside = ui.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !list_rect.contains(p))); diff --git a/crates/ui-egui/src/panels/artboards/tests.rs b/crates/ui-egui/src/panels/artboards/tests.rs index 15bab000b..edae3a32e 100644 --- a/crates/ui-egui/src/panels/artboards/tests.rs +++ b/crates/ui-egui/src/panels/artboards/tests.rs @@ -994,6 +994,50 @@ fn dragging_a_row_onto_new_artboard_duplicates_it() { assert_eq!(app.view().unwrap().artboard, 1); } +/// #4687: a row dragged above or below another row reorders the artboards there (a line shows +/// where it lands), in one undo step, and the moved artboard becomes the active one. +#[test] +fn dragging_a_row_onto_another_row_reorders_the_artboards() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 3})).unwrap(); + let ctx = egui::Context::default(); + let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: screen, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes + }; + let shapes = frame(&mut app, vec![]); + let name_at = |label: &str| { + shapes + .iter() + .find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text() == label => Some(t.pos + vec2(4.0, 4.0)), + _ => None, + }) + .unwrap() + }; + let (first, last) = (name_at("Artboard 1"), name_at("Artboard 3")); + let names = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect::>(); + let button = |at, pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + // Artboard 1 dragged onto the lower half of Artboard 3's row lands below it. + let below = last + vec2(0.0, 8.0); + frame(&mut app, vec![egui::Event::PointerMoved(first), button(first, true)]); + frame(&mut app, vec![egui::Event::PointerMoved(first + vec2(0.0, 12.0))]); + let held = frame(&mut app, vec![egui::Event::PointerMoved(below)]); + let accent = Tokens::get(&ctx).accent; + assert!( + held.iter().any(|c| matches!(&c.shape, egui::Shape::LineSegment { stroke, .. } if stroke.color == accent)), + "a line shows where it lands" + ); + frame(&mut app, vec![button(below, false)]); + assert_eq!(names(&app), ["Artboard 2", "Artboard 3", "Artboard 1"]); + assert_eq!(app.view().unwrap().artboard, 2, "the moved artboard is active"); + // One undo step puts it back. + app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(names(&app), ["Artboard 1", "Artboard 2", "Artboard 3"]); +} + /// A click in a row's lock column locks its artboard and shows the lock next to the name, a /// second click unlocks it, and each click is one undo step. Delete Artboard leaves a locked /// artboard. diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index f65ecdaf6..ce5ff4a34 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -1173,6 +1173,13 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Tate-chu-yoko"), has, tcy) { format(app, json!({"tateChuYoko": !tcy})); } + // Standard Vertical Roman Alignment: the selected half-width characters stand upright in + // vertical type instead of being rotated. Checked when every selected character has it; a + // mixed selection turns it on. + let roman = shared(app, |s| s.standard_vertical_roman) == Some(true); + if menu_item(ui, tl!("Standard Vertical Roman Alignment"), has, roman) { + format(app, json!({"standardVerticalRoman": !roman})); + } ui.separator(); } // Digits: the numeral system the selected characters' digits are drawn in, with the Middle @@ -1209,7 +1216,6 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("System Layout"), open, system) { app.run("type.systemLayout", json!({})).ok(); } - menu_item(ui, tl!("Standard Vertical Roman Alignment"), false, false); ui.separator(); // The installed fonts are always listed; this picks up fonts installed since the app started. #[cfg(not(target_arch = "wasm32"))] @@ -1238,9 +1244,10 @@ fn fractional_widths_on(app: &VectorcraftApp) -> bool { /// method, and the characters the panel acts on ([`char_cmd`]) take tracking 0, Auto leading and /// kerning, no baseline shift, 100% scales, no rotation, no underline or strikethrough, All Caps /// and Small Caps off, the normal position (neither superscript nor subscript), Roman Baseline -/// character alignment, Proportional Metrics, Tate-chu-yoko and No Break off, the Default character -/// direction and the Default digits, whether or not Show East Asian Options and Show Indic Options -/// are on. Inside an undo group that is already open, the reset is part of that group's step. +/// character alignment, Proportional Metrics, Tate-chu-yoko, Standard Vertical Roman Alignment and +/// No Break off, the Default character direction and the Default digits, whether or not Show East +/// Asian Options and Show Indic Options are on. Inside an undo group that is already open, the +/// reset is part of that group's step. fn reset_panel(app: &mut VectorcraftApp) { // Typing in progress stays an undo step of its own. end_typing(app); @@ -1255,7 +1262,8 @@ fn reset_panel(app: &mut VectorcraftApp) { json!({ "kerning": "auto", "baselineShift": 0, "hScale": 100, "vScale": 100, "rotation": 0, "underline": false, "strikethrough": false, "allCaps": false, "smallCaps": d.small_caps.is_some(), "position": d.position.id(), "charAlign": d.char_align, - "proportionalMetrics": d.proportional_metrics, "tateChuYoko": d.tate_chu_yoko, "noBreak": d.no_break, "charDirection": "default", + "proportionalMetrics": d.proportional_metrics, "tateChuYoko": d.tate_chu_yoko, + "standardVerticalRoman": d.standard_vertical_roman, "noBreak": d.no_break, "charDirection": "default", "digits": d.digits }), ); diff --git a/crates/ui-egui/src/panels/character/tests.rs b/crates/ui-egui/src/panels/character/tests.rs index 3990bd057..d7cbcfc11 100644 --- a/crates/ui-egui/src/panels/character/tests.rs +++ b/crates/ui-egui/src/panels/character/tests.rs @@ -618,6 +618,42 @@ fn tate_chu_yoko_menu_item_shows_with_east_asian_options_and_toggles() { assert!(!on(&app)); } +/// Standard Vertical Roman Alignment is in the panel menu with the East Asian options. Choosing it +/// marks the selected characters, one undo step each way; its check shows only while every +/// selected character has it, and a mixed selection turns it on. +#[test] +fn standard_vertical_roman_alignment_menu_item_toggles() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "ABC"})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let ctx = egui::Context::default(); + let label = "Standard Vertical Roman Alignment"; + assert!(!crate::tests_labels::painted_text(&mut app, menu).contains(label)); + app.session.prefs.show_east_asian_options = true; + let on = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().all(|r| r.style.standard_vertical_roman), + _ => panic!("text"), + }; + // Only the first letter has it: the item is unchecked. + app.session.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 1, "standardVerticalRoman": true})).unwrap(); + let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let before = undo(&app); + assert!(!on(&app)); + assert!(!crate::tests_labels::painted_text(&mut app, menu).contains(&format!("✓ {label}"))); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); + let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == label).unwrap_or_else(|| panic!("{texts:?}")).1; + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert!(on(&app)); + assert_eq!(undo(&app), before + 1); + assert!(crate::tests_labels::painted_text(&mut app, menu).contains(&format!("✓ {label}"))); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); + let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == label).unwrap().1; + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert!(!on(&app)); + assert_eq!(undo(&app), before + 2); +} + /// Digits is in the panel menu with the Middle Eastern (Indic) options only, as Paragraph /// Direction is in the Paragraph panel. #[test] @@ -807,6 +843,7 @@ fn reset_panel_resets_every_attribute_but_the_font() { s.char_align != d.char_align, s.proportional_metrics != d.proportional_metrics, s.tate_chu_yoko != d.tate_chu_yoko, + s.standard_vertical_roman != d.standard_vertical_roman, s.no_break != d.no_break, s.char_direction != d.char_direction, s.digits != d.digits, @@ -818,17 +855,17 @@ fn reset_panel_resets_every_attribute_but_the_font() { json!({ "id": id, "tracking": 50, "leading": 30, "kerning": 20, "baselineShift": 3, "hScale": 120, "vScale": 90, "rotation": 15, "underline": true, "strikethrough": true, "allCaps": true, "smallCaps": true, "position": "superscript", - "charAlign": "emBoxCenter", "proportionalMetrics": true, "tateChuYoko": true, "noBreak": true, - "charDirection": "rightToLeft", "digits": "hindi" + "charAlign": "emBoxCenter", "proportionalMetrics": true, "tateChuYoko": true, "standardVerticalRoman": true, + "noBreak": true, "charDirection": "rightToLeft", "digits": "hindi" }), ) .unwrap(); }; let plain = runs(&app)[0].1.clone(); - assert_eq!(differs(&plain, &CharStyle::default()), [false; 18]); + assert_eq!(differs(&plain, &CharStyle::default()), [false; 19]); custom(&mut app); let styled = runs(&app)[0].1.clone(); - assert_eq!(differs(&styled, &CharStyle::default()), [true; 18]); + assert_eq!(differs(&styled, &CharStyle::default()), [true; 19]); app.run("select.set", json!({"ids": [id]})).unwrap(); reset_panel(&mut app); assert_eq!(runs(&app), [("One 12".to_string(), plain.clone())]); diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index ea7afa520..e69e97c46 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -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 { + 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, std::ops::Range) = 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) -> (Vec<(String, Pos2)>, Vec) { + fn shapes(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec) -> Vec { 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 = 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) -> (Vec<(String, Pos2)>, Vec) { + 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 { + fn walk(s: &Shape, fill: Color32, out: &mut Vec) { + 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 = 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); diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index c048421c3..f1d5cabb0 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -1225,17 +1225,18 @@ mod tests { #[test] fn menus_and_tooltips_show_the_backtick_key_as_it_types() { let mut ui = UiState::default(); - crate::keymap::set_override(&mut ui.shortcut_overrides, "edit.clear", "Cmd+~"); + // Not Cmd+~: macOS reserves it, so the override would leave Edit › Clear on Delete there. + crate::keymap::set_override(&mut ui.shortcut_overrides, "edit.clear", "Alt+~"); crate::keymap::set_override(&mut ui.shortcut_overrides, "tool:measure", "~"); sync(&ui); let measure = vectorcraft_tools::tool_info("measure").unwrap(); let press = |key| egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: Modifiers::NONE }; let clear = || menus::shown_shortcut(menus::shortcut_of("edit.clear").unwrap()); note_backtick_text(&[press(Key::Backtick), egui::Event::Text("`".into())]); - assert_eq!(clear(), menus::pretty_shortcut("Cmd+~")); + assert_eq!(clear(), menus::pretty_shortcut("Alt+~")); assert!(crate::toolbar::tip(measure).ends_with("(~)")); note_backtick_text(&[press(Key::Backtick), egui::Event::Text("\"".into())]); - assert_eq!(clear(), menus::pretty_shortcut("Cmd+\"")); + assert_eq!(clear(), menus::pretty_shortcut("Alt+\"")); assert!(crate::toolbar::tip(measure).ends_with("(\")"), "{}", crate::toolbar::tip(measure)); assert_eq!(menus::shown_shortcut("Shift+~"), menus::pretty_shortcut("Shift+~")); sync(&UiState::default()); diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index ad71fb397..27e0f5bbf 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -845,6 +845,38 @@ fn arrows_while_dragging_a_grid_change_its_rows_and_columns() { assert_eq!(grid(&mut app, &[Key::ArrowLeft, Key::ArrowDown]), (rows, columns + 1)); } +/// Grid letters change the stored skews once per press while a polar grid is dragged, plain or +/// with Shift; with Alt, Ctrl or Cmd they do not reach the grid. +#[test] +fn grid_drag_letters_take_plain_and_shift_presses() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.select_tool("polarGrid"); + let view = app.view_info(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 80.0), view).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 300.0, 220.0), view).unwrap(); + let skews = |app: &VectorcraftApp| (app.session.tool_options()["radialSkew"].clone(), app.session.tool_options()["concentricSkew"].clone()); + frame(&mut app, press(Key::V, Modifiers::NONE)); + assert_eq!(skews(&app), (json!(10.0), json!(0.0))); + frame(&mut app, press(Key::F, Modifiers::SHIFT)); + assert_eq!(skews(&app), (json!(0.0), json!(0.0))); + frame(&mut app, press(Key::V, Modifiers::SHIFT)); + assert_eq!(skews(&app), (json!(10.0), json!(0.0))); + frame(&mut app, press(Key::X, Modifiers::SHIFT)); + assert_eq!(skews(&app), (json!(10.0), json!(-10.0))); + frame(&mut app, press(Key::C, Modifiers::SHIFT)); + assert_eq!(skews(&app), (json!(10.0), json!(0.0))); + frame(&mut app, press(Key::X, Modifiers::ALT)); + assert_eq!(skews(&app), (json!(10.0), json!(0.0))); + for key in [Key::F, Key::X] { + frame(&mut app, press(key, command_key())); + assert_eq!(skews(&app), (json!(10.0), json!(0.0)), "{key:?} with Ctrl/Cmd"); + } + app.session.pointer(&PointerEvent::new(PointerKind::Up, 300.0, 220.0), view).unwrap(); + assert_eq!(skews(&app), (json!(10.0), json!(0.0)), "the options stay after release"); +} + /// A press of `key` with `m` held. fn press(key: Key, m: Modifiers) -> Vec { vec![egui::Event::ModifiersChanged(m), egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: m }] diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index a3bcaa83f..5ab3e389e 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -377,6 +377,8 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result crate::dialogs::freehand::open(app, tool), // The Live Paint Selection tool: its Tool Options (what a click selects, the highlight). "livePaintSelection" => crate::dialogs::live_paint_selection::open(app), + // The Live Paint Bucket: its options (what a click paints, the highlight). + "livePaintBucket" => crate::dialogs::live_paint_bucket::open(app), // The Symbolism tools: the brush they share. _ if vectorcraft_tools::settings::SYMBOLISM.contains(&tool) => Ok(crate::dialogs::symbolism_options::open(app, tool)), // The graph tools: Graph Type for the selected graph. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a3858ef8e..2ca9181e3 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -84,7 +84,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); the page's drawing of it (often in pieces) is left out. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); the page's drawing of it (often in pieces) is left out. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. ## Format by format diff --git a/docs/mcp.md b/docs/mcp.md index 2a4e8e507..06d30e20c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -421,7 +421,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `draw_path` | `{points \| d, closed?, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `points` is `[[x,y],…]` or `[{x,y,in?,out?,smooth?},…]`. `d` is SVG path data. The options are below. | | `draw_shape` | `{shape, …geometry, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `rectangle`/`ellipse`: `x,y,width,height` (plus `radius` for corners). `polygon`: `cx,cy,radius,sides`. `star`: `cx,cy,radius1,radius2,points`. `line`: `x1,y1,x2,y2`. | | `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. Returns `{applied, objects}`: `objects` is the number of objects painted, as the `objects` of the `paint.setFill`, `paint.setStroke` and `stroke.set` replies (the largest of them): a selected group counts the objects inside it. When it is 0, a `note` says that only the paint for new art changed. | -| `press_key` | `{key, mods?}` | Remote: a real key event. Headless: runs the command or tool bound to that shortcut, or sends the key to the busy tool (digits too: `5` while dragging with the Perspective Selection tool; `e` while dragging a Selection or Group Selection marquee; `c` and `f` while dragging out an arc). | +| `press_key` | `{key, mods?}` | Remote: a real key event. Headless: runs the command or tool bound to that shortcut, or sends the key to the busy tool (digits too: `5` while dragging with the Perspective Selection tool; `e` while dragging a Selection or Group Selection marquee; `c` and `f` while dragging out an arc; `f`, `v`, `x` and `c` while dragging out a rectangular or polar grid). | | `type_text` | `{text}` | Remote only. | | `answer_dialog` | `{set?: [{field, value?}], action?}` | Remote only. Sets fields of the app's open dialog in the order given with one `ui.dialog.set {fields}` (a missing `value` is `null`). The app stores all of them or none. When the app refuses the `ui.dialog.set`, for example because no dialog is open, the call is an error and `action` is not sent. Then `action` `confirm` (OK, `ui.dialog.confirm`) or `cancel` (`ui.dialog.cancel`). Give `set`, `action` or both; `set` holds at most 256 fields. `inspect_ui` shows the open dialog and its fields as `ui.dialog`. Returns `{dialog, result}`: the dialog after the fields were set (or `null`) and the reply to `confirm` or `cancel` (or `null`). After `invoke_menu` `app.quit`, `file.close` or `file.closeAll` replies `{"pending": "saveChanges"}`, `{action: "confirm"}` saves, `{set: [{field: "discard", value: true}], action: "confirm"}` closes without saving and `{action: "cancel"}` cancels the close or quit. | | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. | @@ -500,6 +500,13 @@ the new values, and they stay as the Spiral tool's `segments` and `decay` option {"name":"pointer_gesture","arguments":{"tool":"spiral","events":[{"kind":"down","x":100,"y":100},{"kind":"drag","x":200,"y":100,"mods":{"alt":true}},{"kind":"drag","x":300,"y":100,"mods":{"alt":true}},{"kind":"up","x":300,"y":100}]}} ``` +While a grid is being dragged out with the Rectangular Grid or Polar Grid tool, `press_key {key: "f"}` and +`{key: "v"}` lower and raise the skew of the horizontal dividers (`rowSkew`) or the radial dividers (`radialSkew`) by +10, and `{key: "x"}` and `{key: "c"}` lower and raise that of the vertical dividers (`columnSkew`) or the concentric +dividers (`concentricSkew`), within ±500. Plain and Shift presses change the skew; with Alt, Ctrl or Cmd held they do not. +The grid drawn on release uses these skews, and they stay as the tool's options for the next grid. With nothing +being dragged, the four letters run their shortcuts as before. + Appearance stacks: an object can carry several fills and strokes (`appearance.addFill`, `appearance.addStroke`), indexed in paint order (0 is painted first, the bottom row of the Appearance panel). Cmd+/ runs `appearance.addFill` and Cmd+Alt+/ runs `appearance.addStroke` (`press_key {key: "/", mods: {cmd: true, alt: true}}`). `paint.setFill`, `paint.setStroke`, @@ -1147,7 +1154,7 @@ a key left out. These commands refuse a string, a number or another value that is not true or false with `` `closed` must be true or false ``, naming the key, and leave the document and its undo history as they were: `clockwise` in `shape.spiral`; `closed` in `shape.arc` and `path.create`; `vertical` and `placeholder` in `text.create` -and `text.createInPath`; `underline`, `strikethrough`, `allCaps`, `proportionalMetrics`, `tateChuYoko` and `noBreak` +and `text.createInPath`; `underline`, `strikethrough`, `allCaps`, `proportionalMetrics`, `tateChuYoko`, `standardVerticalRoman` and `noBreak` in `text.setRangeStyle`; `effective` in `text.getRange`; `on` in `type.opticalMarginAlignment`, `type.systemLayout` and `type.fractionalWidths`; `remove` and `overhang` in `type.ruby`; `expandInstances` in `symbol.delete`; `alt` in `symbol.spray` and `symbol.adjust`; `spot` and `global` in `swatch.new` and `swatch.edit`; `unlink` in @@ -1701,6 +1708,15 @@ already walks Image Trace. Short agent entry points: release, and Escape between the press and the release removes the marquee so that the release runs nothing. The tool does not highlight or select the faces and edges of a Live Paint group that is locked, on a locked layer or on a locked artboard, or in isolation mode outside the isolated art. + The Live Paint Bucket's options (`tool.setOption {tool: "livePaintBucket", values}`; double-clicking the tool opens + them, dialog `livePaintBucketOptions`) are `paintFills` (on), `paintStrokes` (off), `highlight` (on), `color` (the + highlight color, `#rrggbb` or a layer color's name; `#ff2a2a`) and `width` (the highlight's width, 1 to 20 screen + pixels; 4). With `paintFills` alone on, a click runs `livePaint.fill` and Shift-click runs `livePaint.strokeEdge`. + With `paintStrokes` alone on, a click runs `livePaint.strokeEdge` on the edge within 4 screen pixels of the pointer, + or nothing when none is in reach, and Shift-click runs `livePaint.fill`. With both on, a click runs + `livePaint.strokeEdge` when an edge is within 4 screen pixels of the pointer and `livePaint.fill` otherwise, and + Shift-click runs `livePaint.fill`. With both off, a click runs nothing. With `highlight` off, the face or edge under + the pointer is not outlined. `livePaint.merge` keeps the bottom Live Paint group's id, name, lock, visibility, transparency, appearance, opacity mask and graphic style link. With a Live Paint group among the objects, `livePaint.make` adds the others to it as `livePaint.merge` does, and so does `livePaint.fill {ids}` when `point` is outside that group's faces. @@ -6085,6 +6101,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 @@ -6404,6 +6431,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 (`.`, 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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8a67a1df1..316843ac3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-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 | @@ -699,11 +699,13 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays by 0.1 radian at each drag update in which the pointer's document y grows, and back by as much at each one in which it falls, Shift held keeps their turn, and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays with the pointer, Shift keeps them in their original orientation (unturned, or turned by a nonzero General › Constrain Angle), and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays with the pointer, Shift keeps them in their original orientation, and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). + While a grid is dragged out, F lowers `rowSkew` (rectangular) or `radialSkew` (polar) by 10 percentage points and V raises it by 10; X lowers `columnSkew` (rectangular) or `concentricSkew` (polar) by 10 and C raises it by 10. Plain and Shift presses work; with Alt, Ctrl or Cmd held they do not, as for the other letters a tool takes while dragging. Each press changes the skew once and updates the preview; values stay within ±500% and remain in the tool options after release. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list the four drag keys and a 10% change. The signs (positive values follow VectorCraft's existing skew convention, so F and X lower the stored value) and one change per press with key repeat ignored are our own rules. While an arc is dragged out, C switches it between open and closed, F switches its base between the X and Y axes in the same box, and Up/Down raise and lower its slope by 10 within −100…100; each press switches once, and the values stay as the Arc tool's options. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list C, F and Up/Down for an arc being dragged out. F as the base switch, the arrows changing the slope, the step of 10 and one switch per press are our own rules. While a spiral is dragged out, Alt adds or removes whole winds of four segments and Ctrl/Cmd changes the decay. The spiral's inner end keeps the distance from the center it had on the first modified drag event, while the outer end follows the pointer. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list Alt-drag to add or subtract winds and Ctrl-drag to change the decay rate; [Draw spirals](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-spirals.html) defines four segments per full wind. Rounding the fitted count to whole winds from the count on the first Alt-only drag event, the held inner radius, the ranges (2…1000 segments, 5…99.99 % decay), Ctrl/Cmd taking precedence when both are held, and the new values staying as Spiral tool options are our own rules. Switching back to Alt uses the current count as its new base while keeping the held inner end. - **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). @@ -889,6 +891,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur When a drag on the Tabs panel's ruler or a value typed in its X field moves a tab stop past another stop, the moved stop stays selected. The Tabs panel menu's Clear All Tabs (`text.tabs.clear`), Delete Tab and Repeat Tab (`text.tabs.set`) can be chosen while the panel's ruler shows tab stops. Delete Tab removes the stop selected on the ruler. Repeat Tab replaces the stops after the selected stop with up to 20 copies of it. The copies are spaced by the selected stop's distance from the stop before it, or from the paragraph's left indent when it is the first stop, and no copy is placed at 2,000 pt or beyond. Clear All Tabs removes every stop, so the default stops every ½ inch apply again. - **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale), Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian and Finnish also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). + Character panel menu › Standard Vertical Roman Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {standardVerticalRoman}`, off by default) stands the selected Latin letters, digits and other half-width characters upright one below the other in vertical type instead of rotating them ([formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); spaces keep their width, tate-chu-yoko blocks stay across the column, and Character Style Options gives it its own checkbox beside Character Alignment. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. The constant alpha soft mask that PDF and `.ai` export draw a CMYK page with transparency through is white, so applications that read every soft mask by its luminosity show the page too (#1359, part of #1286). Isolation mode and opacity mask editing exclude each other ([transparency and blending modes](https://web.archive.org/web/20230102045151/https://helpx.adobe.com/illustrator/using/transparency-blending-modes.html)): in isolation mode a click or Alt-click on the mask thumbnail does not edit the mask, and Make Mask is off for one object; while a mask is edited, Enter Isolation Mode and Object › Perspective › Edit Text are off, the Contextual Task Bar leaves out Isolate, and Quick Actions leave out Isolate Group. Pattern editing mode and opacity mask editing exclude each other, by a rule of VectorCraft itself: in pattern editing mode a click or Alt-click on the mask thumbnail does not edit the mask, and Make Mask is off for one object; while a mask is edited, Object › Pattern › Make and Edit Pattern are off. @@ -928,6 +931,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Create Strokes uses exact squared Euclidean distances and Guo–Hall thinning in bounded per-blob crops. Maximal-disc pruning, split-crossing merges and degree-two joins simplify its skeleton graph. The whole-blob width rule and ink fitting remain; 46 code-drawn inputs measure ends and junctions in [`image-trace-quality.md`](image-trace-quality.md#centre-lines). Junction degrees measure 44/45 correct in each input variant; remaining spurs are recorded. This is a measured skeleton improvement; the Image Trace score, missing per-feature selection and width controls, and estimates remain unchanged. Object › Live Paint › Merge adds the other selected paths and Live Paint groups to the bottom selected Live Paint group ([Live Paint groups](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/live-paint-groups.html)), which keeps its id, the paint of its faces and edges, and its name, lock, visibility, transparency, appearance, opacity mask and graphic style link. Object › Live Paint › Make with a Live Paint group selected, and a Live Paint Bucket click on a selected path beside a selected Live Paint group, add the paths to that group the same way. Make, Merge and the Live Paint Bucket take paths, compound paths and groups of them; with type, an image or another object among the selection they report that it has to be converted to paths first and change nothing, as type and raster images can't go into a Live Paint group directly ([Create Live Paint groups](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)); before, such an object went among the group's hidden sources and vanished from the canvas. Evidence: `tests_build::live_paint_refuses_type_and_images_and_changes_nothing`. With the Live Paint Bucket selected, Alt-click on vector art samples its fill, stroke and stroke weight into the paint defaults ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)). On a placed image it samples the image pixel as a solid fill with no stroke. Alt+Shift-click samples only the color under the pointer, including an intermediate gradient color or image pixels, into the active fill or stroke ([Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). Neither click paints or changes the selection or selected art, and the next Bucket click paints with the sampled paint. Evidence: `tests_build::live_paint_bucket_alt_sample_paints_next_face_without_changing_selection`. + Double-clicking the Live Paint Bucket opens Live Paint Bucket Options ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)): Paint Fills (on) and Paint Strokes (off) set whether a click paints faces, edges or both, Highlight turns the outline of the face or edge under the pointer on or off, and Color and Width set it (red and 4 px by default). With both on, a click paints the edge within 4 px of the pointer, else the face, and Shift switches to Paint Fills only; with one on, Shift switches to the other. The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintBucket"}`). Evidence: `builder::tests::{bucket_options_choose_what_a_click_paints, bucket_options_set_the_highlight}`, `dialogs::live_paint_bucket::tests::double_clicking_the_tool_opens_its_options_and_ok_sets_them`. Live Paint Gap Options (Object › Live Paint › Gap Options…, `livePaint.gapOptions`): with Gap Detection on, paint stops at the gaps that run from a loose end of a path to the nearest path, up to Small (3 pt), Medium (6 pt), Large (12 pt) or a custom size; Close Gaps With Paths adds an unpainted path across each gap; Preview applies the options live and draws the gaps in the Gap Preview Color. The options are saved, copied and undone with the group, and Merge keeps the bottom group's options when its Gap Detection is on, or else turns Gap Detection on with Small when another merged group has it on. When Gap Detection is off in every merged group, it stays off. View › Show Live Paint Gaps (`view.livePaintGaps`) draws the gaps of the selected Live Paint groups up to their Paint Stops At size, each in its group's Gap Preview Color, until Hide Live Paint Gaps or Hide Edges hides them ([Live Paint gaps](https://web.archive.org/web/20260105111201/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/find-and-close-gaps-in-live-paint-groups.html)). Double-clicking the Live Paint Selection tool opens its Tool Options ([Live Paint Selection tool options](https://web.archive.org/web/20250904012025/https://helpx.adobe.com/illustrator/using/selecting-objects.html)): with Select Fills off, a click selects an edge instead of a face; with Select Strokes off, Alt-click selects a face instead of an edge; Color and Width set the color and width of the highlight on the face or edge under the pointer (red and 4 px by default). The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintSelection"}`). @@ -1063,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`). @@ -1266,6 +1271,8 @@ applies to fills). Tests: `color::gradient::tests` (perceptual midpoint, expansi `pdf::tests::dithered_perceptual_gradients_export_as_images_with_noise_on_the_fill` and the Gradient panel's `the_method_menu_makes_the_gradient_perceptual`. Scores and hours unchanged. +PDF export writes a Perceptual linear or radial gradient whose stops are tints of one spot color (and paper white) as a Separation shading in that ink instead of an image, so the ink keeps its plate ([swatches](https://helpx.adobe.com/illustrator/using/using-creating-swatches.html): "Gradient colors can be assigned as CMYK process colors, RGB colors, or a Spot color."; [separations](https://helpx.adobe.com/illustrator/using/printing-color-separations.html): "a separate plate is created for each spot color"). Its tints follow the curve's stops, each the tint with the OKLab lightness of the color there (VectorCraft's own rule). Dithered fills, freeform gradients and Perceptual gradients that mix a spot color with other colors are still images, and the export warns that their spot colors are written in process colors. Tests: `color::gradient::tests::ink_tints_follow_the_perceptual_curve`, `engine::tests::linked_stops::perceptual_spot_gradients_export_as_separation_shadings` and `spot_gradients_drawn_as_images_warn`. + ## Artwork Cleanup add-on (2026-10-30) A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Artwork Cleanup and its @@ -1426,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 `_YYYY-MM-DD_HH-MM.` (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. @@ -1438,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 | diff --git a/docs/type-parity.md b/docs/type-parity.md index 50f4e44bc..e13ad6680 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-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 | @@ -59,6 +59,7 @@ against Illustrator's public documentation; **estimated** unless noted. | CJK: kinsoku (Hard, Soft, None), Line-end Punctuation Half Width, tate-chu-yoko (auto) | done / partial | custom kinsoku sets, the Mojikumi Settings dialog and the other mojikumi sets missing | | CJK: Proportional Metrics (`palt` in horizontal type, `vpal` in vertical type) | done | #966 | | CJK: manual tate-chu-yoko | done | Character panel menu › Tate-chu-yoko (`text.setFormat` / `text.setRangeStyle {tateChuYoko}`), with Show East Asian Options: marked characters stand side by side across one em of a vertical column, squeezed to the em when wider; two or three half-width digits still do so without the mark; horizontal type keeps the mark and does not move them. Up/Down and Left/Right settings remain. Evidence: `tests_vertical::manual_tate_chu_yoko_sets_marked_characters_across_one_em`, `typecmd::tate_chu_yoko_is_a_character_attribute_saved_only_when_on` | +| CJK: Standard Vertical Roman Alignment | done | Character panel menu › Standard Vertical Roman Alignment (`text.setFormat` / `text.setRangeStyle {standardVerticalRoman}`), with Show East Asian Options, off by default: in vertical type the marked half-width characters (Latin letters, digits and other characters that would lie on their side) stand upright one below the other, each in its upright cell; spaces keep their width and tate-chu-yoko blocks stay across the column; horizontal type keeps the mark and does not change. Character Style Options has its own checkbox for it. Evidence: `tests_vertical::standard_vertical_roman_alignment_stands_half_width_characters_upright`, `typecmd::standard_vertical_roman_is_a_character_attribute_saved_only_when_on` | | CJK: ruby, manual tate-chu-yoko | partial | ruby (ふりがな) for live text (`type.ruby`): a reading over a byte range, above (right of the column in vertical type) or below, start/centre/end alignment, size, overhang or squeeze; saved natively, laid out with its line growing, drawn on the canvas, in SVG and PDF (as outlines where ruby is present), in EPS, EMF/WMF and legacy `.ai` (legacy `.ai` writes type with ruby as outlines), and kept by Create Outlines, Expand Appearance and flattening, each glyph where the layout puts it (`typecmd::ruby_tests::create_outlines_keeps_the_ruby_where_it_is_laid_out`); manual tate-chu-yoko missing | #633 | | Language per character (Character panel › Language, character styles), text shaped in it | done | `text.setFormat` / `text.setRangeStyle {language}` by name or BCP 47 tag, one of Document Setup's languages (Hindi, Marathi, Gujarati, Bengali, Punjabi, Tamil, Telugu, Kannada, Malayalam, Odia and Urdu added, #4943; Serbian: Cyrillic, Serbian: Latin and Croatian); the shaper gets the tag, so the font's language forms apply (OpenType `locl`: Turkish keeps `fi` apart and the dot on its i, #4940); new type takes the document's language; saved as the tag only when set, carried by character styles (#4946). Not yet: hyphenation and spelling by it, `.ai`/PDF import of a text's language, paragraph styles. Evidence: `features::tests::language_picks_the_fonts_forms`, `features::tests::serbian_gets_the_fonts_serbian_forms`, `typecmd::area_tests::language_is_a_character_attribute_saved_only_when_set` | | No Break | done | Character panel menu (`text.setFormat` / `text.setRangeStyle {noBreak}`): the marked characters stay on one line, including across a space; a run that does not fit after earlier text moves together, and a run longer than the line stays together. Evidence: `layout::wrapping_tests::no_break_keeps_marked_characters_on_one_line`, `tests::no_break_keeps_marked_words_on_one_line`, `typecmd::no_break_is_a_character_attribute_saved_only_when_on` | @@ -72,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 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6dc32fc85..3c618c46b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-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 (Artboards panel: drag a row to reorder the artboards, #4687) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -228,6 +228,7 @@ tools, never checked side by side) isn't captured row by row. | Escape while typing ends the edit, keeps the type selected and switches to the Selection tool | done | #1802 (#1686, #1656) | | Selection tool: selected area type and type on a path show an in port and an out port (empty, an arrow where threaded, the red "+" of overflow); a click on either loads the text cursor; a click on a closed path, on empty canvas or a drag threads the text there, after the clicked frame from its out port, before it from its in port; Escape or another tool drops it; a double-click on a threaded port breaks the thread there | partial | #2572 (`text.thread.add`, `before` for the in port); the out port sits on the frame's right side above the bottom-right corner handle and the in port on its left side below the top-left one, so the handles can still be grabbed; a double-click on a threaded port breaks the thread (`text.thread.break`, [archived help](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html)); the loaded cursor borrows the I-beam | | Shift-click selects several artboards (Artboard tool, Artboards panel); they move, Alt-copy and delete together | done | `artboard.move`/`artboard.delete` `indices` (#1139); panel menu, trash and Properties share selected-set deletion; all-selected retains artboard 1 and a single board cannot be deleted, with pointer-event and Undo/Redo coverage | +| Artboards panel: drag a row above or below another to reorder the artboards (as Move Up / Move Down do) | done | #4687; a line shows where the row lands, `artboard.reorder` in one undo step, the moved artboard becomes active; public forum answers describe the drag | | Artboards panel › Delete Empty Artboards | done | `artboard.deleteEmpty`; one undo step, guides go too, locked artboards stay, and artboard 1 when every artboard is empty and none is locked; an artboard is empty when no object (hidden, locked and template art included; guides not) has visual bounds that meet or touch it, a group counting by its members and a clip group or clipping layer by its members inside the clip (our reading of "no artwork"; the pages give no finer rule); [spec](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html) | | Artboard Delete/Backspace delete selected boards while protecting artwork, the last board and active drags | done | Headless shortcut regressions in `tests/distortkeys.rs` cover retaining the original first board when all are selected, single-board protection, artwork, saved state and Undo/Redo; the navigator follows the tool’s surviving board through repeated deletion and history changes. | | Artboard navigator synchronizes the active tool selection before deletion | done | Actual status-bar Next → Delete/Backspace and Next → Properties Delete regressions in `chrome.rs` cover surviving board records, preserved artwork and Undo/Redo; other tools keep object selection and history. **Tab-switch follow-up:** document-tab changes align the Artboard tool with the displayed board. Actual A → B → A tab clicks followed by Properties Delete and controls for same-tab multiselection, Move/Scale Artwork options and other tools passed, along with historical full CI on `a0671777`. [Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area). **Clean-tab close:** with the Artboard tool active, closing a clean active tab restores the surviving document's displayed board before Properties Delete. | @@ -337,10 +338,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 `_YYYY-MM-DD_HH-MM.` 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 +394,9 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | Artboards panel: a row dragged above or below another reorders the artboards (#4687); scores and estimates unchanged | +| 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) | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 0516321ab..de6ff5815 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -294,7 +294,7 @@ fn cmd_ci() -> Result<(), String> { ("assets", Box::new(|| assets::run(&root()))), ("brands", Box::new(|| brands::run(&root()))), ("cursors", Box::new(|| cursors::run(&root()))), - ("i18n-report", Box::new(cmd_i18n_report)), + ("i18n-report", Box::new(cmd_ci_i18n_report)), ("layers", Box::new(cmd_layers)), ("wasm", Box::new(cmd_wasm)), ]; @@ -349,3 +349,13 @@ fn cmd_i18n_report() -> Result<(), String> { c.args(["test", "-p", "vectorcraft-ui-egui", "--lib", "--", "i18n::tests::untranslated_report", "--exact", "--nocapture"]); run(c, "cargo test -p vectorcraft-ui-egui --lib -- i18n::tests::untranslated_report --exact --nocapture") } + +/// [`cmd_i18n_report`] in `ci`, selecting the packages and targets the test stage did so it +/// reuses that build: with `-p vectorcraft-ui-egui --lib` alone cargo resolves features +/// differently and recompiled for about 3.5 minutes. Every other test binary runs with no test +/// matching the filter. +fn cmd_ci_i18n_report() -> Result<(), String> { + let mut c = cargo(); + c.args(["test", "--workspace", "--lib", "--bins", "--tests", "--", "i18n::tests::untranslated_report", "--exact", "--nocapture"]); + run(c, "cargo test --workspace --lib --bins --tests -- i18n::tests::untranslated_report --exact --nocapture") +}