Merge main into feat/character-anti-aliasing-method-r

# Conflicts:
#	crates/engine/src/cmd/panelcmds.rs
#	crates/ui-egui/src/panels/character.rs
This commit is contained in:
dotCooCoo
2026-10-31 21:29:22 -07:00
108 changed files with 5300 additions and 199 deletions
+1
View File
@@ -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:
+3 -1
View File
@@ -9,7 +9,7 @@
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Progress log row for the Arrange Tools add-on's Sort Objects; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Progress log row for the Path Precision Tools add-on's Expand Dashes into Paths; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
@@ -129,6 +129,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
## Progress log
- 2026-11-01: Expand Dashes into Paths joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): each selected path's dashed stroke becomes separate open paths, one per dash, laid out exactly as the stroke draws them (pattern, offset, Align Dashes to Corners and Path Ends), each keeping the stroke with the dashes off, so the gaps are real gaps for cutters and plotters, as one undo step (`addon.pathtools.expandDashes`, #4922).
- 2026-11-01: Randomize Opacity joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): each selected object gets its own opacity at random between a minimum and a maximum percent, with a seed that repeats the result, as one undo step (`addon.arrange.randomizeOpacity`, #4925).
- 2026-11-01: Sort Objects joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): the selected objects are sorted by width, height, area, position, opacity or stacking order (small to large, large to small or at random) and take another of those in that order, trading their values or stepping them evenly, as one undo step (`addon.arrange.sort`, #4917).
- 2026-11-01: New Symbols from Layers joins the Document Tools add-on (Object › Plug-ins › Document Tools): one symbol of each top-level layer's art (the layers highlighted in the Layers panel when two or more are, else every layer), named after the layer, made as New Symbol makes one; Convert Layers to Symbols also puts an instance of each in place of its layer's art. Empty layers are skipped and reported; one undo step. A VectorCraft addition: headline numbers unchanged.
@@ -210,6 +211,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap
| Date | Change | Summary |
|---|---|---|
| 2026-11-01 | minor | Progress log row for the Path Precision Tools add-on's Expand Dashes into Paths (#4922); headline numbers unchanged. |
| 2026-11-01 | minor | Progress log row for the Arrange Tools add-on's Randomize Opacity (#4925); headline numbers unchanged. |
| 2026-11-01 | minor | Progress log row for the Arrange Tools add-on's Sort Objects (#4917); headline numbers unchanged. |
| 2026-11-01 | minor | Progress log row for the Document Tools add-on's New Symbols from Layers (#4927); headline numbers unchanged. |
+45
View File
@@ -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<Vec<(f32, f32, f32)>> {
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) {
+31
View File
@@ -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());
}
+22 -1
View File
@@ -20,7 +20,7 @@ use std::sync::Arc;
use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint};
use vectorcraft_geom::kurbo::ParamCurveArclen;
use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, SubPath, Vec2};
use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2};
use crate::appearance::{Appearance, AppearanceItem, Dash, StrokeLayer, WidthProfile};
use crate::live::{BlendOrientation, BlendSpacing, BlendSpec};
@@ -1029,6 +1029,27 @@ impl Rail {
}
}
/// A box round everything a blend of `keys` can paint once [`Rail::place`] puts its keys and steps
/// on its spine, turned to it with Align to Path: the spine's box grown by three times the farthest
/// any key paints from its centre. A step's shape lies within that distance of the point on the
/// spine it is moved to, and so does its own centre, which it turns about. `None` when nothing
/// moves.
pub(crate) fn rail_reach(keys: &[Arc<Node>], spec: &BlendSpec, symbols: Option<&crate::SymbolBoxes>) -> Option<Rect> {
if spec.spine.is_none() && spec.orientation != BlendOrientation::AlignToPath {
return None;
}
let spine = blend_spine(keys, spec)?.0.bounds()?;
let radius = keys
.iter()
.filter_map(|k| {
let (c, b) = (center_of(k), k.visual(symbols)?);
let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x0, b.y1), Point::new(b.x1, b.y1)];
Some(corners.into_iter().map(|q| (q - c).hypot()).fold(0.0, f64::max))
})
.fold(0.0, f64::max);
Some(spine.inflate(3.0 * radius, 3.0 * radius))
}
// =====================================================================================
// Blend evaluation
// =====================================================================================
+161 -3
View File
@@ -1,7 +1,9 @@
//! Hit testing over the document tree.
use std::cell::RefCell;
use std::collections::HashSet;
use std::sync::OnceLock;
use std::rc::Rc;
use std::sync::{Arc, OnceLock};
use vectorcraft_geom::hit::{fill_contains, stroke_contains};
use vectorcraft_geom::{BezPath, FillRule, Point, Rect};
@@ -99,6 +101,128 @@ pub(crate) fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)>
IMAGE_PAINT.get().map_or_else(Vec::new, |f| f(n))
}
/// One level of evaluation of a live object in a document, as the canvas draws it: type in an
/// envelope as its distorted glyph outlines, symbol instances as their art. Effects are evaluated
/// above this crate, so the engine registers one with [`set_live_art`]. It reads the object, the
/// document's symbols and patterns, and what [`LiveInputs`] stands for, and nothing else.
pub type LiveArt = fn(Option<&Document>, &Node) -> Vec<Node>;
/// A number that changes whenever something outside the document that [`LiveArt`] reads changes,
/// such as the installed fonts or the plug-ins.
pub type LiveInputs = fn() -> u64;
static LIVE_ART: OnceLock<(LiveArt, LiveInputs)> = OnceLock::new();
/// Register how a live object's art is evaluated for clicks, and the number that changes with what
/// else it reads (the first registration stays).
pub fn set_live_art(f: LiveArt, inputs: LiveInputs) {
// Already set: the same engine, registered by another session.
let _ = LIVE_ART.set((f, inputs));
}
/// The registered [`LiveInputs`] now, 0 while nothing is registered.
fn live_inputs() -> u64 {
LIVE_ART.get().map_or(0, |(_, inputs)| inputs())
}
/// A live object evaluated by [`live_art`] on this thread: the object, its document's symbols (by
/// name and art) and patterns and the [`LiveInputs`] as they were, the art and its [`art_size`].
struct EvaluatedLive {
node: Node,
symbols: Vec<(String, Arc<Node>)>,
patterns: Vec<crate::PatternDef>,
inputs: u64,
art: Rc<Vec<Node>>,
size: usize,
}
impl EvaluatedLive {
/// Is this the evaluation of `n` in `doc`, with inputs `inputs`, as they are now?
fn is_of(&self, doc: &Document, n: &Node, inputs: u64) -> bool {
self.inputs == inputs
&& self.symbols.len() == doc.symbols.len()
&& self.symbols.iter().zip(&doc.symbols).all(|((name, art), s)| *name == s.name && Arc::ptr_eq(art, &s.art))
&& self.node == *n
&& self.patterns == doc.patterns
}
}
/// How many evaluations [`live_art`] keeps on each thread: the pointer moving in a blend's or an
/// envelope's stroke band hits it again and again, and an envelope can hold a blend.
const LIVE_KEPT: usize = 4;
/// Most [`art_size`] the evaluations [`live_art`] keeps on one thread add up to. Art larger than
/// this is not kept.
const LIVE_KEPT_SIZE: usize = 250_000;
/// The size of `art` for [`LIVE_KEPT_SIZE`]: its objects and the anchors of their paths.
fn art_size(art: &[Node]) -> usize {
let mut size = 0usize;
for n in art {
n.walk(&mut |c| size = size.saturating_add(1 + c.path_data().map_or(0, |p| p.anchor_count())));
}
size
}
/// Does `n`, or the art of a symbol or a pattern of `doc`, hold a placed document? Its art changes
/// when the file is read, so [`live_art`] does not keep an evaluation of `n`.
fn holds_placed(doc: &Document, n: &Node) -> bool {
let mut placed = false;
let mut find = |c: &Node| placed |= matches!(c.kind, NodeKind::PlacedDocument(_));
n.walk(&mut find);
doc.symbols.iter().for_each(|s| s.art.walk(&mut find));
doc.patterns.iter().flat_map(|p| &p.art).for_each(|a| a.walk(&mut find));
placed
}
thread_local! {
/// The evaluations [`live_art`] keeps, the latest first.
static EVALUATED: RefCell<Vec<EvaluatedLive>> = const { RefCell::new(Vec::new()) };
}
/// The art live object `n` of `doc` draws ([`set_live_art`]); [`crate::live::expand_live`] while
/// nothing is registered. The art is evaluated again when `n`, a symbol of `doc`, a pattern of
/// `doc` or the [`LiveInputs`] have changed since, and on every click while it or the document's
/// symbols or patterns hold a placed document ([`holds_placed`]).
fn live_art(doc: &Document, n: &Node) -> Rc<Vec<Node>> {
let inputs = live_inputs();
let kept = EVALUATED.with_borrow_mut(|kept| {
let i = kept.iter().position(|e| e.is_of(doc, n, inputs))?;
let e = kept.remove(i);
let art = e.art.clone();
kept.insert(0, e);
Some(art)
});
if let Some(art) = kept {
return art;
}
#[cfg(test)]
LIVE_EVALUATED.with(|c| c.set(c.get() + 1));
let art = Rc::new(LIVE_ART.get().map_or_else(|| crate::live::expand_live(n), |(f, _)| f(Some(doc), n)));
let size = art_size(&art);
if size > LIVE_KEPT_SIZE || holds_placed(doc, n) {
return art;
}
let symbols = doc.symbols.iter().map(|s| (s.name.clone(), s.art.clone())).collect();
let e = EvaluatedLive { node: n.clone(), symbols, patterns: doc.patterns.clone(), inputs, art: art.clone(), size };
EVALUATED.with_borrow_mut(|kept| {
kept.insert(0, e);
kept.truncate(LIVE_KEPT);
let mut total = 0usize;
kept.retain(|e| {
total = total.saturating_add(e.size);
total <= LIVE_KEPT_SIZE
});
});
art
}
#[cfg(test)]
thread_local! {
/// How many live objects [`live_art`] evaluated on this thread.
static LIVE_EVALUATED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Topmost editable object under `p`.
pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
hit_test_skipping(doc, p, opt, &|_| false, true)
@@ -565,7 +689,15 @@ fn hit_stroke(n: &Node, bp: &vectorcraft_geom::BezPath, rule: vectorcraft_geom::
stroke_contains(bp, 0.0, opt.tol, p).then_some(HitKind::Outline)
}
#[cfg(test)]
thread_local! {
/// How many objects [`hit_leaf`] tested on this thread.
static LEAVES_TESTED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option<HitKind> {
#[cfg(test)]
LEAVES_TESTED.with(|c| c.set(c.get() + 1));
match &n.kind {
NodeKind::Path { path, rule, .. } => {
let bp = path.to_bezpath();
@@ -612,9 +744,18 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio
AppearanceItem::Fill(_) => (!opt.path_only && fill_contains(bp, *rule, p)).then_some(HitKind::Fill),
})
}
NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } => {
n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds)
}
// A blend or an envelope is hit within its bounds, and outside them where its evaluated
// art paints: a blend's keys and steps on a spine can reach past the keys and the spine,
// and the strokes of an envelope's distorted contents past its bounds.
NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
if n.geometric_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) {
return Some(HitKind::Bounds);
}
live_art(symbols.document(), n).iter().find_map(|g| hit_any(g, p, opt, symbols))
}
NodeKind::Repeat(r) => r.expand(Some(symbols.document())).iter().find_map(|g| hit_any(g, p, opt, symbols)),
NodeKind::Mesh(m) => {
let bp = m.outline().to_bezpath();
@@ -651,8 +792,25 @@ fn on_type_path(n: &Node, t: &crate::TextObject, p: Point, tol: f64) -> bool {
/// Hit anywhere in an evaluated subtree (groups recurse; leaves use [`hit_leaf`]).
fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option<HitKind> {
// Hidden art does not hit.
if !n.visible {
return None;
}
// Nor art whose reach does not hold `p`, as in [`may_hit`]: a blend's steps far from `p` are
// passed by without being measured.
if !edits_contents(n) && n.extras().shaper.is_none() && n.reach_bounds_with(symbols).is_some_and(|b| !b.inflate(opt.tol, opt.tol).contains(p)) {
return None;
}
match &n.kind {
NodeKind::Group { children, .. } => children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols)),
// A clip group only hits inside its clipping path.
NodeKind::Group { children, clip } => {
if *clip && children.first().is_some_and(|c| !clip_contains(c, p)) {
return None;
}
children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols))
}
// An intertwine group hits by its members, as on the canvas.
NodeKind::Intertwine { children } => children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols)),
_ => hit_leaf(n, p, opt, symbols),
}
}
+145
View File
@@ -429,6 +429,151 @@ fn a_marquee_meets_blends_and_envelopes_by_their_box() {
}
}
/// A click just outside a blend's bounds, in the reach of its keys' strokes, tests only the steps
/// whose own reach holds the point, not every step of the evaluated blend.
#[test]
fn a_click_in_a_blends_stroke_band_tests_only_the_steps_that_reach_it() {
let mut d = Document::new(1000.0, 1000.0);
let l = d.layers[0].id;
// 10 pt circles with 4 pt strokes, y 295 to 305, from x 10 to x 890: 200 steps about 4.3 pt apart.
let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0);
let circle = |id, x: f64| Arc::new(Node::path(id, shapes::ellipse(Rect::new(x, 295.0, x + 10.0, 305.0)), stroked.clone()));
let blend = d.alloc_id();
let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)];
let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() };
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap();
let count = |p: Point| {
LEAVES_TESTED.with(|c| c.set(0));
let h = hit_test(&d, p, HitOptions::default()).map(|h| h.leaf);
(h, LEAVES_TESTED.with(std::cell::Cell::get))
};
// On the steps' strokes halfway between the keys, 4 pt above the bounds: a few steps reach it.
let (h, tested) = count(Point::new(450.0, 291.0));
assert_eq!(h, Some(blend));
assert!(tested < 20, "{tested} objects tested");
// In the corner of the reach, outside every stroke: nothing hits.
let (h, tested) = count(Point::new(6.0, 291.0));
assert_eq!(h, None);
assert!(tested < 20, "{tested} objects tested");
}
/// An intertwine group in a live object's evaluated art (a blend key a spine moves) hits by its
/// members.
#[test]
fn evaluated_art_hits_an_intertwine_group_by_its_members() {
let d = Document::new(200.0, 200.0);
let square = Arc::new(Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art()));
let group = Node::new(NodeId(8), NodeKind::Intertwine { children: vec![square] });
let symbols = d.symbol_boxes();
assert_eq!(hit_any(&group, Point::new(10.0, 10.0), HitOptions::default(), &symbols), Some(HitKind::Fill));
assert_eq!(hit_any(&group, Point::new(50.0, 50.0), HitOptions::default(), &symbols), None);
}
/// The pointer moving in a blend's stroke band evaluates the blend once, and again after the blend
/// or a symbol of the document changes.
#[test]
fn clicks_in_a_blends_stroke_band_evaluate_it_once_until_it_changes() {
let mut d = Document::new(1000.0, 1000.0);
let l = d.layers[0].id;
let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0);
let circle = |id, x: f64| Arc::new(Node::path(id, shapes::ellipse(Rect::new(x, 295.0, x + 10.0, 305.0)), stroked.clone()));
let blend = d.alloc_id();
let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)];
let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() };
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap();
let evaluated = || LIVE_EVALUATED.with(std::cell::Cell::get);
let before = evaluated();
for x in [448.0, 450.0, 452.0] {
assert_eq!(hit_test(&d, Point::new(x, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend));
}
assert_eq!(evaluated(), before + 1);
// 100 steps 8.7 pt apart: a step still reaches (450, 291).
if let Some(NodeKind::Blend { spec, .. }) = d.node_mut(blend).map(|n| &mut n.kind) {
spec.spacing = crate::live::BlendSpacing::Steps(100);
}
assert_eq!(hit_test(&d, Point::new(450.0, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend));
assert_eq!(evaluated(), before + 2);
let art = circle(d.alloc_id(), 0.0);
d.symbols.push(crate::Symbol { dynamic: false, name: "dot".into(), art });
hit_test(&d, Point::new(450.0, 291.0), HitOptions::default());
assert_eq!(evaluated(), before + 3);
hit_test(&d, Point::new(450.0, 291.0), HitOptions::default());
assert_eq!(evaluated(), before + 3);
}
/// Evaluated art larger than [`LIVE_KEPT_SIZE`] is not kept: each click in the blend's stroke band
/// evaluates it again.
#[test]
fn evaluated_art_past_the_kept_size_is_not_kept() {
let mut d = Document::new(1000.0, 1000.0);
let l = d.layers[0].id;
let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0);
// A 10 pt circle drawn with 2,000 anchors: 202 objects of 2,000 anchors each with 200 steps.
let circle = |id, x: f64| {
let points: Vec<Point> = (0..2000)
.map(|i| {
let a = f64::from(i) * std::f64::consts::TAU / 2000.0;
Point::new(x + 5.0 + 5.0 * a.cos(), 300.0 + 5.0 * a.sin())
})
.collect();
let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&points, true));
Arc::new(Node::path(id, path, stroked.clone()))
};
let blend = d.alloc_id();
let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)];
let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() };
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap();
let evaluated = || LIVE_EVALUATED.with(std::cell::Cell::get);
let before = evaluated();
for x in [448.0, 450.0] {
assert_eq!(hit_test(&d, Point::new(x, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend));
}
assert_eq!(evaluated(), before + 2);
}
/// While the document's symbols hold a placed document, whose art changes when its file is read,
/// each click in a blend's stroke band evaluates the blend again.
#[test]
fn evaluated_art_is_not_kept_while_a_placed_document_may_show_in_it() {
let mut d = Document::new(1000.0, 1000.0);
let l = d.layers[0].id;
let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0);
let circle = |id, x: f64| Arc::new(Node::path(id, shapes::ellipse(Rect::new(x, 295.0, x + 10.0, 305.0)), stroked.clone()));
let blend = d.alloc_id();
let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)];
let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() };
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap();
let placed = crate::PlacedDocument {
link: crate::LinkInfo::new("/art/logo.vectorcraft"),
key: "logo".into(),
bounding: false,
page_box: crate::PageBox::Crop,
width: 80.0,
height: 40.0,
xf: vectorcraft_geom::Affine::IDENTITY,
placement: Default::default(),
};
let art = Arc::new(Node::new(d.alloc_id(), NodeKind::PlacedDocument(Box::new(placed))));
d.symbols.push(crate::Symbol { dynamic: false, name: "logo".into(), art });
let evaluated = || LIVE_EVALUATED.with(std::cell::Cell::get);
let before = evaluated();
for x in [448.0, 450.0] {
assert_eq!(hit_test(&d, Point::new(x, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend));
}
assert_eq!(evaluated(), before + 2);
}
/// A kept evaluation is the evaluation of the object only while the fonts and plug-ins
/// ([`LiveInputs`]) are as they were.
#[test]
fn a_kept_evaluation_is_stale_after_the_live_inputs_change() {
let d = Document::new(100.0, 100.0);
let n = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
let e = EvaluatedLive { node: n.clone(), symbols: vec![], patterns: vec![], inputs: 7, art: std::rc::Rc::new(vec![]), size: 0 };
assert!(e.is_of(&d, &n, 7));
assert!(!e.is_of(&d, &n, 8));
}
/// A rectangle (40, 40)–(140, 140) clipping a filled 200 × 200 square drawn over its edges, in a
/// clip group (or, `layer`, as the first layer's clipping mask): the group (or the layer), the
/// clipping path and the art.
+7 -1
View File
@@ -198,7 +198,8 @@ impl Node {
/// ([`Appearance::outset`]) instead of measuring along their shape. An image's box takes in
/// the shapes its own fills and strokes paint ([`crate::hit::set_image_paint`]). Groups, layers
/// without a clipping mask and Intertwine groups take in their members' boxes, grown by their
/// own strokes. Symbol instances are measured as in [`Node::visual_bounds`].
/// own strokes. Symbol instances are measured as in [`Node::visual_bounds`]. A blend's box
/// takes in where its spine can put its keys and steps.
pub fn reach_bounds(&self) -> Option<Rect> {
self.reach(None)
}
@@ -234,6 +235,11 @@ impl Node {
let o = if let crate::AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 };
vectorcraft_geom::union_opt(acc, (!bp.elements().is_empty()).then(|| bp.bounding_box().inflate(o, o)))
}),
// Keys and steps placed on a spine, turned to it with Align to Path, can reach past the
// keys and the spine.
NodeKind::Blend { children, spec } => {
vectorcraft_geom::union_opt(self.visual(symbols), crate::blend::rail_reach(children, spec, symbols))
}
_ => self.visual(symbols),
}
}
+9
View File
@@ -104,6 +104,14 @@ pub struct CharStyle {
/// <https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html>
#[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.
/// <https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html>
#[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,
+42 -1
View File
@@ -1,6 +1,7 @@
//! Document Tools (`addon.document.*`), a built-in add-on: Save a Timestamped Copy writes a copy of
//! the document named with the date and time through File › Save a Copy's save; New Symbols from
//! Layers makes a symbol of each layer's art as New Symbol does. Its submenu (Object › Plug-ins ›
//! Layers makes a symbol of each layer's art as New Symbol does; Export All Open Documents writes a
//! copy of every open document to one folder as Save a Copy does (#4920). Its submenu (Object › Plug-ins ›
//! Document Tools) is the UI's.
#[cfg(test)]
@@ -30,6 +31,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_clock,
save_timestamped_copy
),
cmd!(
"addon.document.exportAllOpen",
"Export All Open Documents",
["Object", "Plug-ins", "Document Tools"],
None,
"{folder, format?: vectorcraft|pdf|svg|svgz|ai|eps (default: pdf)} write a copy of every open document, in tab order, to `folder` as File › Save a Copy writes it, each named after its document (<name>.<ext>; -2, -3… added when that name is taken, so no file is replaced): every document keeps its path, title and modified state, and no undo step is made. A document that can't be written is listed in `failed` and the others are still written; the active document stays active → {files: [{title, path, warnings}], failed: [{title, error}], format}",
has_doc,
export_all_open
),
cmd!(
"addon.document.symbolsFromLayers",
"New Symbols from Layers",
@@ -88,6 +98,37 @@ fn save_timestamped_copy(s: &mut Session, p: &Value) -> Result<Value> {
fileio::save_document(s, SaveMode::Copy, &json!({ "path": path, "format": format.id }))
}
fn export_all_open(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.document.exportAllOpen";
let format = fileio::save_format(Some(str_param(p, "format").unwrap_or("pdf")), None).map_err(|e| bad(C, e))?;
let folder = str_param(p, "folder").map(str::trim).filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "give `folder`"))?.to_string();
let ext = format.extensions.first().copied().ok_or_else(|| bad(C, "the format has no file extension"))?;
let (mut files, mut failed, mut written) = (vec![], vec![], Vec::<String>::new());
let active = s.active;
for i in 0..s.docs.len() {
// Each document in turn is the one Save a Copy writes; switching tabs this way leaves every
// document's tool and interaction alone, and the active one comes back below.
s.active = Some(i);
let Ok(st) = s.doc() else { continue };
let title = st.doc.title.clone();
let stem = fileio::file_stem(st.path.as_deref().unwrap_or(&title));
// Names the copies already took count as taken, as do files on disk.
let Some(path) = free_name(&folder, &stem, ext, |p| written.iter().any(|w| w == p) || std::path::Path::new(p).exists()) else {
failed.push(json!({ "title": title, "error": "every numbered name for the copy is taken" }));
continue;
};
match fileio::save_document(s, SaveMode::Copy, &json!({ "path": path, "format": format.id })) {
Ok(r) => {
written.push(path.clone());
files.push(json!({ "title": title, "path": r.get("path").cloned().unwrap_or(json!(path)), "warnings": r.get("warnings").cloned().unwrap_or(json!([])) }));
}
Err(e) => failed.push(json!({ "title": title, "error": e.to_string() })),
}
}
s.active = active;
Ok(json!({ "files": files, "failed": failed, "format": format.id }))
}
/// `n` with each layer in it, and itself, made a group of the same art and clipping (a symbol holds
/// no layers).
fn without_layers(n: &Node) -> Node {
@@ -194,3 +194,26 @@ fn the_layers_highlighted_in_the_layers_panel_are_the_default() {
let r = s.execute("addon.document.symbolsFromLayers", &json!({})).unwrap();
assert_eq!((r["symbols"].clone(), r["skipped"].as_array().unwrap().len()), (json!(["Icons"]), 1));
}
/// #4920: Export All Open Documents writes an SVG of each open document; a folder that can't be
/// written lists every document in `failed`, and either way the active document stays active.
#[test]
fn export_all_open_documents_writes_each_and_reports_failures() {
let dir = std::env::temp_dir().join(format!("vc-export-all-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100, "name": "one"})).unwrap();
s.execute("file.new", &json!({"width": 100, "height": 100, "name": "two"})).unwrap();
s.execute("document.activate", &json!({"index": 0})).unwrap();
let r = s.execute("addon.document.exportAllOpen", &json!({"folder": dir, "format": "svg"})).unwrap();
assert_eq!(r["files"].as_array().unwrap().len(), 2, "{r}");
assert!(dir.join("one.svg").exists() && dir.join("two.svg").exists());
assert_eq!(s.active_index(), Some(0));
assert!(s.documents().iter().all(|d| d.path.is_none()), "the documents stay unsaved");
let missing = dir.join("missing").join("deeper");
let r = s.execute("addon.document.exportAllOpen", &json!({"folder": missing, "format": "svg"})).unwrap();
assert_eq!((r["files"].as_array().unwrap().len(), r["failed"].as_array().unwrap().len()), (0, 2), "{r}");
assert_eq!(s.active_index(), Some(0));
let _ = std::fs::remove_dir_all(&dir);
}
+110 -3
View File
@@ -2,7 +2,8 @@
//! the Extend Path tool's extension, the Measure & Inspect tool's readouts, the Anchor Inspector's
//! anchor edits, Split / Add Points at Crossings, Slide Anchor Along Path, the Corner Tool's corners
//! ([`corners`]), the Parametric Shape Tool's shapes ([`shape`]), the Shared Edge Merge Tool's
//! merges with Keep Shared Points Together ([`shared_edge`]), and Divide into Dashes.
//! merges with Keep Shared Points Together ([`shared_edge`]), Divide into Dashes and Expand Dashes
//! into Paths.
//! Its tools, panels and submenu (Object › Plug-ins › Path Precision Tools) are the tools crate's
//! and the UI's.
@@ -17,7 +18,7 @@ mod tests;
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Value, json};
use vectorcraft_doc::{AnchorRef, Document, NodeId, NodeKind};
use vectorcraft_doc::{AnchorRef, Appearance, AppearanceItem, Document, LineCap, NodeId, NodeKind};
use vectorcraft_geom::{PathData, Point, SubPath};
use vectorcraft_pathops::addons::{self as po, pathtools as pt};
@@ -40,7 +41,7 @@ const MIN_RADIUS: f64 = 0.05;
const MAX_DIAMETER: f64 = 2500.0;
/// The farthest an end is extended at once (pt).
const MAX_EXTEND: f64 = 1e6;
/// Most pieces Divide into Dashes makes at once.
/// Most pieces Divide into Dashes and Expand Dashes into Paths make at once.
const MAX_PIECES: usize = 100_000;
/// Most anchors Add Points puts on each segment.
const MAX_ADD: u64 = pt::MAX_ADDED_PER_SEGMENT as u64;
@@ -164,6 +165,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
divide_dashes
),
cmd!(
"addon.pathtools.expandDashes",
"Expand Dashes into Paths",
["Object", "Plug-ins", "Path Precision Tools"],
None,
"{ids?: [id…] (default: the selection)} turn the dashed stroke of each path and compound path under the objects into separate open paths, one per dash, laid out as the stroke draws them (its dash pattern, offset and Align Dashes to Corners and Path Ends), each keeping the path's appearance with the dashes off, so the gaps are real gaps: a dash running all the way round a closed path stays closed, and with a round or projecting cap a dash of no length (a dot) becomes a path on its point. The top stroke's dashes are the ones expanded; its arrowheads go, and the path's fills stay on it, below its dashes. Paths without a dashed stroke stay as they are. One undo step; the dashes are selected; at most 100000 → {ids, dashes}",
has_selection,
expand_dashes
),
];
v.extend(shape::specs());
v.extend(corners::specs());
@@ -793,3 +803,100 @@ fn divide_dashes(s: &mut Session, p: &Value) -> Result<Value> {
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "dashes": dashes }))
}
// ---------- Expand Dashes into Paths ----------
/// The paths and compound paths under `roots` whose top stroke is dashed: a compound path's
/// members stand for it, which holds their paint. Clipping paths are left alone.
fn dashed_paths(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
let editable = d.editable();
let mut seen = BTreeSet::new();
let mut out = vec![];
for id in paths_under(d, roots) {
let compound = d.parent_of(id).and_then(|p| d.node(p)).filter(|p| matches!(p.kind, NodeKind::Compound { .. }));
let Some(n) = compound.or_else(|| d.node(id)) else { continue };
let dashed = n.appearance.stroke().and_then(|st| st.dash.as_ref()).is_some_and(|dash| dash.is_dashed());
if dashed && !matches!(n.kind, NodeKind::Path { clipping: true, .. }) && editable(n.id) && seen.insert(n.id) {
out.push(n.id);
}
}
out
}
fn expand_dashes(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.expandDashes";
let too_many = || bad(C, "that makes too many pieces (at most 100000): make the dashes longer");
let roots = checked_ids_or_selection(s, p, C)?;
let d = &s.doc()?.doc;
// Each dashed path and its dashes, as the canvas draws them.
let mut changed: Vec<(NodeId, Vec<SubPath>)> = vec![];
let mut count = 0;
for id in dashed_paths(d, &roots) {
let Some(n) = d.node(id) else { continue };
let (Some(st), Some(bp)) = (n.appearance.stroke(), n.stroke_path()) else { continue };
let Some(dash) = &st.dash else { continue };
// The line the stroke draws: the path, its ends trimmed under any arrowheads.
let line = vectorcraft_render::effects::stroke::stroke_pieces(&bp, st).line;
// A pattern too fine to draw is drawn solid: it has too many dashes to expand.
let dashed = vectorcraft_render::effects::stroke::dash(&line, dash).ok_or_else(too_many)?;
let mut pieces = PathData::from_bezpath(&dashed.path).subpaths;
// A dot paints its cap; a path that stays on its point is drawn so too. Under a butt cap it
// paints nothing.
if st.cap != LineCap::Butt {
pieces.extend(dashed.dots.iter().map(|dot| SubPath::polyline(&[dot.at, dot.at], false)));
}
count += pieces.len();
if count > MAX_PIECES {
return Err(too_many());
}
if !pieces.is_empty() {
changed.push((id, pieces));
}
}
if changed.is_empty() {
return Err(bad(C, "select paths with dashed strokes to expand"));
}
let ids = s.edit("Expand Dashes into Paths", |d, sel| {
let mut all = vec![];
let mut beside = Beside::replacing(d, &changed.iter().map(|(id, _)| *id).collect::<Vec<_>>());
for (id, pieces) in changed {
let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?;
let (NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. }) = &n.kind else { continue };
let rule = *rule;
let filled = |i: &AppearanceItem| matches!(i, AppearanceItem::Fill(f) if f.visible && !f.paint.is_none());
// The dashes: open paths with the strokes (the top one's dashes and arrowheads off).
let mut look = Appearance::clone(&n.appearance);
look.items.retain(|i| !matches!(i, AppearanceItem::Fill(_)));
if let Some(st) = look.stroke_mut() {
(st.dash, st.start_arrow, st.end_arrow) = (None, None, None);
}
let mut dash = n.clone();
dash.kind = NodeKind::Path { path: PathData::default(), rule, live: None, clipping: false, guide: false };
dash.appearance = look.into();
// The fills stay on the path, below its dashes; without fills it goes.
let mut parts = vec![];
if n.appearance.items.iter().any(filled) {
let mut fill = n.clone();
let mut look = Appearance::clone(&n.appearance);
look.items.retain(filled);
fill.appearance = look.into();
parts.push(fill);
}
for sp in pieces {
let mut piece = d.reid(&dash);
if let NodeKind::Path { path, .. } = &mut piece.kind {
*path = PathData::single(sp);
}
all.push(piece.id);
parts.push(piece);
}
beside.put(d, id, parts)?;
}
beside.finish(d)?;
// The dashes join the objects selected (whole, in one go: there may be many).
let before = std::mem::take(&mut sel.objects);
sel.set(before.into_iter().filter(|id| d.node(*id).is_some()).chain(all.iter().copied()));
Ok(all)
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "dashes": ids.len() }))
}
@@ -748,3 +748,126 @@ fn divide_into_dashes_refuses_bad_params_and_too_many_pieces() {
s.execute("select.none", &json!({})).unwrap();
refuses(&mut s, "addon.pathtools.divideDashes", &[json!({})]);
}
/// The dashes made of `r`'s ids: (first x, last x) of each, by its anchors.
fn dash_spans(s: &Session, r: &Value) -> Vec<(f64, f64)> {
ids(r).iter().map(|id| path(s, *id).bounds().map(|b| (b.x0, b.x1)).unwrap()).collect()
}
fn near(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6)
}
#[test]
fn expand_dashes_makes_an_open_path_of_each_dash_keeping_the_stroke() {
let mut s = session();
let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0)], false);
select(&mut s, &[l]);
s.execute("paint.setFill", &json!({"none": true})).unwrap();
s.execute("stroke.set", &json!({"weight": 3, "cap": "round", "dash": [30, 10]})).unwrap();
let n = steps(&s);
let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap();
assert_eq!(steps(&s), n + 1, "one undo step");
assert!(near(&dash_spans(&s, &r), &[(0.0, 30.0), (40.0, 70.0), (80.0, 100.0)]), "{:?}", dash_spans(&s, &r));
assert_eq!(r["dashes"], json!(3));
let st = s.doc().unwrap();
for id in ids(&r) {
let node = st.doc.node(id).unwrap();
let stroke = node.appearance.stroke().unwrap();
assert_eq!((stroke.width, stroke.cap, stroke.dash.clone()), (3.0, vectorcraft_doc::LineCap::Round, None), "the stroke stays, dashes off");
assert!(!path(&s, id).subpaths[0].closed);
}
assert_eq!(st.selection.objects, ids(&r));
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(l).unwrap().appearance.stroke().unwrap().dash.is_some());
// The offset shifts the pattern as the stroke draws it: 20 into the first dash.
s.execute("stroke.set", &json!({"dashOffset": 20})).unwrap();
let r = s.execute("addon.pathtools.expandDashes", &json!({"ids": [l.0]})).unwrap();
assert!(near(&dash_spans(&s, &r), &[(0.0, 10.0), (20.0, 50.0), (60.0, 90.0)]), "{:?}", dash_spans(&s, &r));
}
#[test]
fn expand_dashes_keeps_the_fill_below_and_follows_aligned_corners() {
let mut s = session();
let rect = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 60})).unwrap());
select(&mut s, &[rect]);
s.execute("stroke.set", &json!({"dash": [20, 20]})).unwrap();
let had_fill = !s.doc().unwrap().doc.node(rect).unwrap().appearance.fill_paint().is_none();
let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap();
// 320 round: eight dashes of 20, a dash bending round each corner it meets.
let pieces = ids(&r);
assert_eq!(pieces.len(), 8);
let st = s.doc().unwrap();
let layer: Vec<NodeId> = st.doc.layers[0].children().unwrap().iter().map(|n| n.id).collect();
if had_fill {
let kept = st.doc.node(rect).unwrap();
assert!(kept.appearance.stroke().is_none() && kept.appearance.fill().is_some(), "the fill stays on the rectangle");
assert_eq!(layer, [vec![rect], pieces.clone()].concat(), "the fill below its dashes");
} else {
assert_eq!(layer, pieces);
}
assert!(pieces.iter().all(|id| st.doc.node(*id).unwrap().appearance.fill().is_none()), "an open dash isn't filled");
s.execute("edit.undo", &json!({})).unwrap();
// Aligned to corners: a dash centred on each corner, the four sides each starting and ending
// on half a dash; 12 dashes along the sides.
s.execute("stroke.set", &json!({"dash": [10, 10], "alignDashes": true})).unwrap();
let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap();
let corners = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 60.0), Point::new(0.0, 60.0)];
let mut centred = 0;
for id in ids(&r) {
let sp = path(&s, id).subpaths[0].clone();
if let [a, b, c] = sp.anchors.as_slice()
&& corners.iter().any(|k| b.p.distance(*k) < 1e-6)
{
assert!((a.p.distance(b.p) - 5.0).abs() < 1e-6 && (c.p.distance(b.p) - 5.0).abs() < 1e-6, "{sp:?}");
centred += 1;
}
}
assert_eq!((ids(&r).len(), centred), (16, 4));
}
#[test]
fn expand_dashes_turns_dots_into_paths_on_their_point_and_releases_compound_paths() {
let mut s = session();
let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0)], false);
select(&mut s, &[l]);
s.execute("stroke.set", &json!({"dash": [0, 20], "cap": "round"})).unwrap();
let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap();
assert_eq!(ids(&r).len(), 6, "a dot every 20 from 0 to 100");
assert!(ids(&r).iter().all(|id| path(&s, *id).bounds().unwrap().width() == 0.0));
s.execute("edit.undo", &json!({})).unwrap();
// Under a butt cap a dot paints nothing, so there is nothing to expand.
s.execute("stroke.set", &json!({"cap": "butt"})).unwrap();
refuses(&mut s, "addon.pathtools.expandDashes", &[json!({})]);
// A compound path's dashes are paths of their own with its stroke.
let a = line(&mut s, &[(0.0, 100.0), (50.0, 100.0)], false);
let b = line(&mut s, &[(0.0, 120.0), (50.0, 120.0)], false);
select(&mut s, &[a, b]);
let c = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap());
s.execute("stroke.set", &json!({"weight": 2, "dash": [20, 10], "cap": "butt"})).unwrap();
let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap();
// Each 50 line: 20, gap, 20.
let st = s.doc().unwrap();
assert_eq!(ids(&r).len(), 4);
for id in ids(&r) {
let n = st.doc.node(id).unwrap();
assert!(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. }), "a plain path");
assert_eq!(n.appearance.stroke_width(), 2.0);
assert!(n.appearance.stroke().unwrap().dash.is_none());
}
assert!(st.doc.node(c).is_none_or(|n| n.appearance.stroke().is_none()), "the compound path's dashes are gone from it");
}
#[test]
fn expand_dashes_refuses_without_dashes_and_past_the_limit() {
let mut s = session();
let l = line(&mut s, &[(0.0, 0.0), (1e5, 0.0)], false);
refuses(&mut s, "addon.pathtools.expandDashes", &[json!({})]);
select(&mut s, &[l]);
refuses(&mut s, "addon.pathtools.expandDashes", &[json!({}), json!({"ids": "x"}), json!({"zzz": 1})]);
// 125000 dashes refused; 1000 are fine.
s.execute("stroke.set", &json!({"dash": [0.4, 0.4]})).unwrap();
refuses(&mut s, "addon.pathtools.expandDashes", &[json!({})]);
s.execute("stroke.set", &json!({"dash": [50, 50]})).unwrap();
assert_eq!(s.execute("addon.pathtools.expandDashes", &json!({})).unwrap()["dashes"], json!(1000));
}
@@ -82,6 +82,28 @@ fn an_illustrator_ai_file_opens_from_its_editing_data() {
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden"]);
}
#[test]
fn an_ai_unread_group_keeps_its_layers_and_live_neighbor() {
let live = format!("0 0 1 0 k\n{}f", ai::rect(10.0, 10.0, 14.0, 14.0));
let unread = format!("u\n0 0 1 0 k\n{}f\nZZ\nU\n{}", ai::rect(50.0, 10.0, 54.0, 14.0), ai::named("Unread"));
let data = ai::editing_data(200.0, 100.0, &(ai::layer("Live", &live) + &ai::layer("Mixed", &unread)));
let page = "0 0 1 0 k 10 10 4 4 re f 1 0 0 0 k 50 10 4 4 re f";
let mut session = Session::new();
let result = open(&mut session, "unread.ai", &ai::ai(&compressed(data.as_bytes()), page), json!({})).unwrap();
let doc = &session.doc().unwrap().doc;
assert_eq!(names(&doc.layers), ["Live", "Mixed"], "{result}");
assert!(matches!(doc.layers[0].children().unwrap()[0].kind, NodeKind::Path { .. }), "{result}");
assert!(matches!(doc.layers[1].children().unwrap()[0].kind, NodeKind::Group { .. }), "{result}");
assert_eq!(doc.layers[1].children().unwrap()[0].name.as_deref(), Some("Unread"), "{result}");
let replacement = &doc.layers[1].children().unwrap()[0].children().unwrap()[0];
assert!(
matches!(replacement.appearance.items.first(), Some(vectorcraft_doc::AppearanceItem::Fill(fill))
if fill.paint == vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(1.0, 0.0, 0.0, 0.0))),
"{result}"
);
assert!(result["warnings"].to_string().contains("`ZZ` (1)"), "{result}");
}
/// A palette of three swatches and a color group `Inks` holding a fourth.
const INKS_PALETTE: &str = "%AI5_BeginPalette\n0 0 Pb\n0 0 0 0 1 1 1 Xa\n(White)\nPc\n0.04 0.3 0 0 81.96 21 -8 (Pink ink) 0 2 Xx\n(Pink ink)\nPc\n\
0.06 0.98 1 0 (Red ink) 0 x\n(Red ink)\nPc\n1 (Inks) 1 Pg\n0.02 0.1 1 0 (Yellow ink) 0 x\n(Yellow ink)\nPc\nPB\n%AI5_EndPalette\n";
+1 -1
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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
),
+96 -11
View File
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
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<Value> {
}
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<Value> {
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<Value> {
} 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<u64> = 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::<Vec<_>>(), 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::<Vec<_>>(), 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();
File diff suppressed because one or more lines are too long
+8 -2
View File
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Option<vectorcraft_doc::ParaDirection>>,
proportional_metrics: Option<bool>,
tate_chu_yoko: Option<bool>,
standard_vertical_roman: Option<bool>,
no_break: Option<bool>,
digits: Option<vectorcraft_doc::Digits>,
/// 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<Self> {
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;
}
+1
View File
@@ -175,6 +175,7 @@ pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
("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)),
@@ -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::<vectorcraft_doc::CharStyle>(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() {
+5
View File
@@ -20,6 +20,11 @@ impl Session {
});
// Clicks find an image's own fills and strokes where their effects draw them.
vectorcraft_doc::hit::set_image_paint(vectorcraft_render::effects::image_paint);
// Clicks find a blend's steps and an envelope's distorted art where the canvas draws them,
// evaluated again when the fonts or the plug-ins change.
vectorcraft_doc::hit::set_live_art(vectorcraft_render::effects::expand_live, || {
vectorcraft_text::FontDb::global().generation().rotate_left(32) ^ vectorcraft_render::effects::plugin_revision()
});
// Exports bake Vector Halftone into the dots the renderer samples.
vectorcraft_render::effects::addons::halftone::set_art(vectorcraft_render::addons::halftone::art);
// OpenType SVG glyphs are drawn as the SVG importer reads their documents.
+30
View File
@@ -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<vectorcraft_doc::Node>]| g.iter().map(|n| format!("{:?}", n.geometric_bounds())).collect::<Vec<_>>();
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).
+75
View File
@@ -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<Value> = (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:?}");
}
+3
View File
@@ -600,6 +600,9 @@ fn structured_junk() {
("addon.pathtools.divideDashes", json!({"dash": 1e-308, "gap": -1e308, "fit": "x"})),
("addon.pathtools.divideDashes", json!({"dash": 0.01, "gap": 1e6, "fit": true, "removeGaps": false})),
("addon.pathtools.divideDashes", json!({"dash": f64::MAX, "gap": [0], "removeGaps": null})),
("addon.pathtools.expandDashes", json!({})),
("addon.pathtools.expandDashes", json!({"ids": [0, 1, 2, 3, u64::MAX]})),
("addon.pathtools.expandDashes", json!({"ids": "x", "junk": f64::MAX})),
// Refused before anything is written: no format of that name, a time that is no time.
("addon.document.saveTimestampedCopy", json!({"format": "png", "folder": 7})),
("addon.document.saveTimestampedCopy", json!({"format": "svg", "time": [1e308], "folder": null})),
+217 -14
View File
@@ -1,6 +1,6 @@
//! Untrusted text files placed with File → Place never crash: any bytes, with any Text Import
//! Options, place as area type or fail with an error, and so do hostile RTF files, Word documents
//! and Word 97-2003 documents.
//! and Word 97-2003 documents, including ones whose compound file container is damaged.
//!
//! `PROPTEST_CASES=20000 cargo test -p vectorcraft-engine --test text_import_fuzz` runs a deeper search.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
@@ -38,8 +38,8 @@ proptest! {
"replaceSpaces": spaces,
});
let p = json!({"name": "fuzz.txt", "dataBase64": vectorcraft_format::base64_encode(&data), "text": text, "thumbnail": 8});
let _ = s.execute("file.place.info", &p);
let _ = s.execute("file.place", &p);
let _ = no_panic(s.execute("file.place.info", &p));
let _ = no_panic(s.execute("file.place", &p));
});
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
}
@@ -55,8 +55,8 @@ proptest! {
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&data), "thumbnail": 8});
let _ = s.execute("file.place.info", &p);
let _ = s.execute("file.place", &p);
let _ = no_panic(s.execute("file.place.info", &p));
let _ = no_panic(s.execute("file.place", &p));
});
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
}
@@ -86,8 +86,8 @@ proptest! {
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&data), "thumbnail": 8});
let _ = s.execute("file.place.info", &p);
let _ = s.execute("file.place", &p);
let _ = no_panic(s.execute("file.place.info", &p));
let _ = no_panic(s.execute("file.place", &p));
});
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
}
@@ -117,20 +117,223 @@ proptest! {
let i = at.index(stream.len());
stream[i] = byte;
}
let mut cf = cfb::CompoundFile::create(std::io::Cursor::new(Vec::new())).unwrap();
std::io::Write::write_all(&mut cf.create_stream("WordDocument").unwrap(), &wd).unwrap();
std::io::Write::write_all(&mut cf.create_stream("1Table").unwrap(), &table).unwrap();
cf.flush().unwrap();
let data = cf.into_inner().into_inner();
let data = compound_file(&wd, &table, cfb::Version::V4);
let r = catch_quiet(|| {
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&data), "thumbnail": 8});
let _ = s.execute("file.place.info", &p);
let _ = s.execute("file.place", &p);
let _ = no_panic(s.execute("file.place.info", &p));
let _ = no_panic(s.execute("file.place", &p));
});
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
}
#[test]
fn any_damaged_doc_container_places_without_panics(
text in prop::collection::vec(prop::sample::select(DOC_CHARS.chars().collect::<Vec<_>>()), 1..60),
compressed in any::<bool>(),
v3 in any::<bool>(),
large in any::<bool>(),
damage in prop::collection::vec(arb_cfb_damage(), 1..4),
name in prop::sample::select(vec!["fuzz.doc", "fuzz.bin"]),
) {
vectorcraft_text::FontDb::global().set_system_fallback(false);
// A letter first, so that the undamaged document has text to place.
let text: String = std::iter::once('a').chain(text).collect();
let (mut wd, table) = word97(&text, compressed, &[], 0, &[], &[], &[]);
// Past the mini stream cutoff (4,096 bytes) the WordDocument stream is kept in sectors the
// FAT chains; below it, both streams are kept in the mini stream the mini FAT chains.
if large {
wd.resize(9000, 0);
}
let mut data = compound_file(&wd, &table, if v3 { cfb::Version::V3 } else { cfb::Version::V4 });
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let p = json!({"name": "fuzz.doc", "dataBase64": vectorcraft_format::base64_encode(&data)});
let placed = s.execute("file.place", &p);
prop_assert!(placed.is_ok(), "the undamaged document doesn't place: {:?}", placed.err());
let layout = CfbLayout::of(&data);
for d in &damage {
d.apply(&mut data, &layout);
}
let r = catch_quiet(|| {
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&data), "thumbnail": 8});
let _ = no_panic(s.execute("file.place.info", &p));
let _ = no_panic(s.execute("file.place", &p));
});
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
}
}
/// `r`, after failing the case if the command panicked. The engine's guard reports a panic as
/// `EngineError::Internal`.
fn no_panic(r: Result<serde_json::Value, vectorcraft_engine::EngineError>) -> Result<serde_json::Value, vectorcraft_engine::EngineError> {
if let Err(vectorcraft_engine::EngineError::Internal { cmd, msg }) = &r {
panic!("{cmd} panicked: {msg}");
}
r
}
/// A compound file of `version` holding the WordDocument stream `wd` and the table stream `table`.
fn compound_file(wd: &[u8], table: &[u8], version: cfb::Version) -> Vec<u8> {
let mut cf = cfb::CompoundFile::create_with_version(version, std::io::Cursor::new(Vec::new())).unwrap();
std::io::Write::write_all(&mut cf.create_stream("WordDocument").unwrap(), wd).unwrap();
std::io::Write::write_all(&mut cf.create_stream("1Table").unwrap(), table).unwrap();
cf.flush().unwrap();
cf.into_inner().into_inner()
}
/// The compound file header's fields (\[MS-CFB\] §2.2) a damage changes: their offset and width.
const CFB_HEADER: [(usize, usize); 17] = [
(0, 4), // signature
(24, 2), // minor version
(26, 2), // major version
(28, 2), // byte order
(30, 2), // sector shift
(32, 2), // mini sector shift
(40, 4), // directory sectors
(44, 4), // FAT sectors
(48, 4), // first directory sector
(52, 4), // transaction signature
(56, 4), // mini stream cutoff
(60, 4), // first mini FAT sector
(64, 4), // mini FAT sectors
(68, 4), // first DIFAT sector
(72, 4), // DIFAT sectors
(76, 4), // DIFAT[0]
(80, 4), // DIFAT[1]
];
/// A directory entry's fields (\[MS-CFB\] §2.6.1) a damage changes: their offset and width.
const CFB_ENTRY: [(usize, usize); 10] = [
(0, 2), // first character of the name
(64, 2), // name length
(66, 1), // object type
(67, 1), // color
(68, 4), // left sibling
(72, 4), // right sibling
(76, 4), // child
(116, 4), // starting sector
(120, 4), // stream size, low
(124, 4), // stream size, high
];
/// Where an undamaged compound file keeps its directory, FAT and mini FAT.
struct CfbLayout {
/// The bytes of one sector.
sector: usize,
/// The offsets of the first directory sector, the first FAT sector and the first mini FAT
/// sector.
directory: usize,
fat: usize,
mini_fat: usize,
}
impl CfbLayout {
fn of(data: &[u8]) -> Self {
let u32_at = |at: usize| u32::from_le_bytes(data[at..at + 4].try_into().unwrap()) as usize;
let sector = 1 << u16::from_le_bytes(data[30..32].try_into().unwrap());
let at = |n: usize| (n + 1) * sector;
let layout = Self { sector, directory: at(u32_at(48)), fat: at(u32_at(76)), mini_fat: at(u32_at(60)) };
// The root entry's name opens the directory, the FAT marks its own sector, and the mini FAT
// has a sector.
assert_eq!(&data[layout.directory..layout.directory + 4], b"R\0o\0");
assert_eq!(u32_at(layout.fat + 4 * u32_at(76)), 0xFFFF_FFFD);
assert!(u32_at(64) >= 1);
layout
}
}
/// A change to the container of a compound file rather than to the bytes of its streams.
#[derive(Clone, Debug)]
enum CfbDamage {
/// A header field set to a value.
Header(prop::sample::Index, u32),
/// A field of one of the first directory sector's entries set to a value.
Entry(prop::sample::Index, prop::sample::Index, u32),
/// An entry of the first FAT sector (the next sector of a chain) set to a value.
Fat(prop::sample::Index, u32),
/// An entry of the first mini FAT sector set to a value.
MiniFat(prop::sample::Index, u32),
/// A byte anywhere set to a value.
Byte(prop::sample::Index, u8),
/// The file cut short.
Truncate(prop::sample::Index),
}
impl CfbDamage {
fn apply(&self, data: &mut Vec<u8>, layout: &CfbLayout) {
let mut put = |at: usize, width: usize, value: u32| {
if let Some(b) = data.get_mut(at..at + width) {
b.copy_from_slice(&value.to_le_bytes()[..width]);
}
};
match self {
Self::Header(field, value) => {
let (at, width) = CFB_HEADER[field.index(CFB_HEADER.len())];
put(at, width, *value);
}
Self::Entry(entry, field, value) => {
let (at, width) = CFB_ENTRY[field.index(CFB_ENTRY.len())];
put(layout.directory + 128 * entry.index(layout.sector / 128) + at, width, *value);
}
Self::Fat(entry, value) => put(layout.fat + 4 * entry.index(layout.sector / 4), 4, *value),
Self::MiniFat(entry, value) => put(layout.mini_fat + 4 * entry.index(layout.sector / 4), 4, *value),
// A file cut to nothing has no byte to change and nothing to cut.
Self::Byte(..) | Self::Truncate(_) if data.is_empty() => {}
Self::Byte(at, value) => {
let i = at.index(data.len());
data[i] = *value;
}
Self::Truncate(at) => data.truncate(at.index(data.len())),
}
}
}
/// A sector number, size, count or shift: the special sector numbers (\[MS-CFB\] §2.1), small and
/// huge values, or any.
fn arb_cfb_value() -> impl Strategy<Value = u32> {
prop_oneof![
3 => prop::sample::select(vec![
0,
1,
2,
3,
6,
7,
9,
12,
13,
16,
31,
64,
0x1000,
0x1001,
0x7FFF_FFFF,
0xFFFF_FFFA,
0xFFFF_FFFB,
0xFFFF_FFFC,
0xFFFF_FFFD,
0xFFFF_FFFE,
0xFFFF_FFFF,
]),
1 => any::<u32>(),
]
}
/// A [`CfbDamage`].
fn arb_cfb_damage() -> impl Strategy<Value = CfbDamage> {
let index = any::<prop::sample::Index>;
prop_oneof![
3 => (index(), arb_cfb_value()).prop_map(|(f, v)| CfbDamage::Header(f, v)),
3 => (index(), index(), arb_cfb_value()).prop_map(|(e, f, v)| CfbDamage::Entry(e, f, v)),
2 => (index(), arb_cfb_value()).prop_map(|(e, v)| CfbDamage::Fat(e, v)),
2 => (index(), arb_cfb_value()).prop_map(|(e, v)| CfbDamage::MiniFat(e, v)),
1 => (index(), any::<u8>()).prop_map(|(at, v)| CfbDamage::Byte(at, v)),
1 => index().prop_map(CfbDamage::Truncate),
]
}
/// Characters of a Word document's text: letters, marks of paragraphs, cells and breaks, fields,
+1 -1
View File
@@ -34,7 +34,7 @@ use crate::ps;
pub(crate) use frames::text_frames;
pub use read::{IMAGE_TOO_LARGE, PaletteSwatches, Structure, artboards, palette, read, variables};
pub(crate) use read::{slot_frame, slot_of};
pub(crate) use read::{join_and, slot_frame, slot_of};
#[cfg(test)]
mod tests;
+2
View File
@@ -68,6 +68,8 @@ pub(super) struct Obj {
pub content: Vec<V>,
/// Vector art enclosed by `X=` … `X+` in an opacity-mask dictionary.
pub art: Option<std::sync::Arc<vectorcraft_doc::Node>>,
/// What that art has that isn't read, reported once the mask is known to be enabled.
pub unread: Vec<String>,
}
impl Obj {
+19 -1
View File
@@ -73,6 +73,7 @@ mod document;
mod ops;
mod types;
pub(crate) use art::join_and;
use art::preview_png;
pub use document::{PaletteSwatches, VariableLibrary, artboards, palette, variables};
pub(crate) use document::{slot_frame, slot_of};
@@ -101,6 +102,10 @@ pub struct Structure {
pub template: Option<[f64; 4]>,
/// What layers that don't show have that isn't read (operators, objects): left out of them.
pub hidden_unread: BTreeSet<String>,
/// Top-level object ids and the unread kinds within them.
pub unread: Vec<(NodeId, Vec<String>)>,
/// Other bounds for previewless placed files whose page art uses the file box's corner.
pub unread_elsewhere: Vec<(NodeId, Rect)>,
/// The names of text objects, by their slot's id.
pub slot_names: HashMap<NodeId, String>,
/// Where the drawn looks lay that objects read with their art style took the place of (the
@@ -162,6 +167,12 @@ struct Reader<'a> {
unknown: BTreeSet<String>,
/// What layers that don't show have that this reader doesn't read.
hidden_unread: BTreeSet<String>,
unread: Vec<(NodeId, Vec<String>)>,
symbol_bounds: HashMap<String, Rect>,
/// Depth shared by pattern and symbol readers.
subreader_depth: usize,
pending_unread_fill: bool,
pending_unread_stroke: bool,
/// The header's bounding boxes and template box.
bbox: Option<[f64; 4]>,
hires: Option<[f64; 4]>,
@@ -209,6 +220,7 @@ struct Reader<'a> {
restyled: Vec<Rect>,
/// See [`Structure::placed_elsewhere`].
placed_elsewhere: Vec<(NodeId, Affine)>,
unread_elsewhere: Vec<(NodeId, Rect)>,
/// CMYK samples converted, by value.
cmyk_cache: HashMap<[u8; 4], [u8; 3]>,
/// A `/ForeignObject`'s `/Data`: its PDF as ASCII85 in comment lines, until the object closes.
@@ -277,6 +289,11 @@ impl<'a> Reader<'a> {
unsupported: BTreeSet::new(),
unknown: BTreeSet::new(),
hidden_unread: BTreeSet::new(),
unread: Vec::new(),
symbol_bounds: HashMap::new(),
subreader_depth: 0,
pending_unread_fill: false,
pending_unread_stroke: false,
bbox: None,
hires: None,
template: None,
@@ -301,6 +318,7 @@ impl<'a> Reader<'a> {
palette: super::palette::Palette::default(),
restyled: vec![],
placed_elsewhere: vec![],
unread_elsewhere: vec![],
mesh: None,
cmyk_cache: HashMap::new(),
done: false,
@@ -332,7 +350,7 @@ impl<'a> Reader<'a> {
let w = std::str::from_utf8(w).unwrap_or("?");
self.op(w, hidden)?;
}
Tok::Comment(c) => self.comment(c)?,
Tok::Comment(c) => self.comment(c, hidden)?,
Tok::Data(d) => self.image(d)?,
Tok::Preview(d) => {
if let Some(p) = self.place.as_mut() {
+60 -8
View File
@@ -12,7 +12,7 @@ impl<'a> Reader<'a> {
/// A text object (`/AI11Text`): an empty group standing for it, named after its story.
pub(super) fn text_slot(&mut self, o: &Obj) -> Result<(), String> {
if self.pattern.is_some() {
self.unreadable("type in a pattern");
self.unreadable("type in a pattern", false);
}
// `1 /FreeUndo`: the type in the drawn look of a text object that has an appearance. Its
// story is one of another text document (`/AI11UndoFreeTextDocument`), which counts its
@@ -171,6 +171,8 @@ impl<'a> Reader<'a> {
"b" => (true, true),
_ => (false, false),
};
// A gradient instance's paint replaces the missing pattern.
let unread = (fill && self.pending_unread_fill && fill_paint.is_none()) || (stroke && self.pending_unread_stroke && stroke_paint.is_none());
let mut items = Vec::new();
if fill && !clip {
items.push(AppearanceItem::Fill(FillLayer {
@@ -212,7 +214,28 @@ impl<'a> Reader<'a> {
.into();
copy
});
let unread_bounds = unread.then(|| node.visual_bounds()).flatten();
let unread_id = node.id;
self.add(node, hidden)?;
if unread && self.pattern.is_some() {
// Art in a pattern's definition painted with a pattern it doesn't have: the tile can't be read.
self.unsupported.insert("pattern fills".into());
} else if unread && ((hidden && !self.in_mask()) || gs.hidden || !self.shown()) {
self.hidden_unread.insert("pattern fills".into());
} else if unread && self.in_mask() {
self.unreadable("pattern fills", false);
} else if unread {
match unread_bounds {
Some(b) if sane(b) && b.area() > 0.0 => {
if !self.mark_unread("pattern fills".into()) {
self.unread.push((unread_id, vec!["pattern fills".into()]));
}
}
_ => {
self.unsupported.insert("pattern fills".into());
}
}
}
if let Some(copy) = copy {
self.clip_fills.insert(copy.id);
self.add(copy, hidden)?;
@@ -224,7 +247,7 @@ impl<'a> Reader<'a> {
pub(super) fn end_mesh(&mut self) -> Result<(), String> {
let Some((m, hidden)) = self.mesh.take() else { return Ok(()) };
if m.unread || m.patches.is_empty() {
self.unreadable("gradient meshes it can't read");
self.unreadable("gradient meshes it can't read", hidden && self.pattern.is_none());
return Ok(());
}
let gs = self.gs.clone();
@@ -461,10 +484,39 @@ impl<'a> Reader<'a> {
/// shows the preview the data keeps of it, as a linked image whose file is missing does. One
/// without a preview, a path or a matrix isn't read.
pub(super) fn end_place(&mut self, hidden: bool) -> Result<(), String> {
let Some(Place { matrix: Some(m), bounds: Some(b), path, preview: Some((png, w, h)) }) = self.place.take().filter(|p| !p.path.is_empty())
else {
self.unreadable("placed files");
return Ok(());
let (m, b, path, png, w, h) = match self.place.take() {
Some(Place { matrix: Some(m), bounds: Some(b), path, preview: Some((png, w, h)) }) if !path.is_empty() => (m, b, path, png, w, h),
other => {
let boxed = other.as_ref().and_then(|p| Some((p.matrix?, p.bounds?))).filter(|(_, b)| sane(*b) && b.area() > 0.0);
let mut have_bounds = false;
if self.shown()
&& !self.gs.hidden
&& !hidden
&& let Some((m, b)) = boxed
{
let centre = self.template.map(|t| ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0));
let origin = centre.map_or(b.origin(), |(x, y)| Point::new(CANVAS_CENTRE - x, CANVAS_CENTRE + y));
let box_at_canvas = Rect::new(origin.x, origin.y, origin.x + b.width(), origin.y + b.height());
let bounds = (self.space() * m).transform_rect_bbox(box_at_canvas);
have_bounds = sane(bounds) && bounds.area() > 0.0;
if have_bounds {
let placeholder_id = self.unread_box("placed files", bounds, hidden)?;
if let Some(placeholder_id) = placeholder_id.filter(|_| self.unread_elsewhere.len() < MAX_PLACED_ELSEWHERE) {
let corner = (self.space() * m).transform_rect_bbox(b);
if sane(corner) && corner.area() > 0.0 && corner != bounds {
self.unread_elsewhere.push((placeholder_id, corner));
}
}
return Ok(());
}
}
if self.shown() && !self.gs.hidden && !hidden && !have_bounds {
self.unsupported.insert("placed files".into());
} else {
self.unreadable("placed files", hidden);
}
return Ok(());
}
};
// The file's pixels (y down) start at the canvas point of art space's origin (the box's own
// corner when the file doesn't say where the canvas is); the preview's are spread over them.
@@ -481,7 +533,7 @@ impl<'a> Reader<'a> {
.filter(|a| a.as_coeffs().iter().all(|v| v.is_finite()));
let xf = self.space() * m * over_file;
if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 {
self.unreadable("placed files");
self.unreadable("placed files", hidden);
return Ok(());
}
// The preview is all the document has of the file: what a missing link shows.
@@ -581,7 +633,7 @@ fn cmyk_rgb([c, m, y, k]: [u8; 4]) -> [u8; 3] {
}
/// `a`, `a and b`, `a, b and c`.
pub(super) fn join_and(items: &[&str]) -> String {
pub(crate) fn join_and(items: &[&str]) -> String {
match items {
[] => String::new(),
[one] => (*one).to_string(),
+143 -13
View File
@@ -21,9 +21,12 @@ impl<'a> Reader<'a> {
after: self.after_object,
mesh_ended: self.mesh_ended,
gs: self.gs.clone(),
pending_unread_fill: self.pending_unread_fill,
pending_unread_stroke: self.pending_unread_stroke,
base,
clips: Vec::new(),
compound_members: false,
unread: Vec::new(),
state: None,
});
self.after_object = false;
@@ -76,10 +79,27 @@ impl<'a> Reader<'a> {
"*U" => self.close(what, |k| matches!(k, Kind::Compound))?,
_ => self.close(what, |k| matches!(k, Kind::Clip))?,
};
let Frame { kind, children: kids, gs, clips, hidden: opened_hidden, compound_members, .. } = f;
let Frame {
kind,
children: kids,
gs,
clips,
hidden: opened_hidden,
compound_members,
unread,
pending_unread_fill,
pending_unread_stroke,
..
} = f;
if matches!(kind, Kind::Clip) {
self.gs = gs.clone();
self.pending_unread_fill = pending_unread_fill;
self.pending_unread_stroke = pending_unread_stroke;
}
// A group holding only clipping paths (`q … u <path> W n U … Q`) doesn't clip on its own: `u`/`U` don't save
// the graphics state as `q`/`Q` do, so its paths clip the clipping group around it, as the file's PDF part draws it.
if matches!(kind, Kind::Group)
&& unread.is_empty()
&& !clips.is_empty()
&& clips.len() == kids.len()
&& matches!(self.frames.last(), Some(Frame { kind: Kind::Clip, .. }))
@@ -109,7 +129,14 @@ impl<'a> Reader<'a> {
// painted copies make a compound of their own, under what it clips.
let (fills, kids): (Vec<Node>, Vec<Node>) = ungrouped(kids).into_iter().partition(|n| self.clip_fills.contains(&n.id));
painted = self.compound(fills, &gs);
let Some(n) = self.compound(kids, &gs) else { return Ok(()) };
let Some(n) = self.compound(kids, &gs) else {
if gs.hidden || !self.shown() {
self.hidden_unread.extend(unread);
} else {
self.unsupported.extend(unread);
}
return Ok(());
};
n
}
// A group with a clipping path (`q` … `Q`, or a `W` path in a group) is a clipping group.
@@ -124,6 +151,13 @@ impl<'a> Reader<'a> {
if compound_members {
self.compound_groups.insert(node.id);
}
if !unread.is_empty() {
if !node.visible || !self.shown() {
self.hidden_unread.extend(unread);
} else {
self.unread.push((node.id, unread));
}
}
self.add(node, opened_hidden || hidden)?;
if let Some(p) = painted {
self.clip_fills.insert(p.id);
@@ -173,6 +207,8 @@ impl<'a> Reader<'a> {
}
pub(super) fn end_layer(&mut self) -> Result<(), String> {
self.pending_unread_fill = false;
self.pending_unread_stroke = false;
let mut f = self.close("LB", |k| matches!(k, Kind::Layer(_)))?;
let state = self.layer_state.take();
// An `Xy` right after the layer's last mesh or blend, then `LB`: the layer's, not theirs.
@@ -208,15 +244,16 @@ impl<'a> Reader<'a> {
Ok(())
}
/// An operator the reader doesn't know: on hidden art it is only noted, on shown art the file
/// is read from its PDF part.
pub(super) fn note_unknown(&mut self, w: &str) {
/// An operator the reader doesn't know is scoped to its top-level object when possible.
pub(super) fn note_unknown(&mut self, w: &str, hidden: bool) {
let op: String = w.chars().take(24).collect();
if !self.shown() {
// A pattern definition or an owning mask can be on `%_` lines while still
// determining visible art. Unknown operators there must remain unread.
if (self.gs.hidden && self.pattern.is_none()) || (hidden && self.pattern.is_none() && !self.in_mask()) || !self.shown() {
if self.hidden_unread.len() < 16 {
self.hidden_unread.insert(format!("`{op}`"));
}
} else if self.unknown.len() < 16 {
} else if !self.defer_to_mask(&format!("`{op}`")) && !self.mark_unread(format!("`{op}`")) && self.unknown.len() < 16 {
self.unknown.insert(op);
}
}
@@ -234,6 +271,8 @@ impl<'a> Reader<'a> {
self.open(Kind::Obj(Box::new(Obj { ty, ..Obj::default() })), hidden)?;
// A dictionary's state starts afresh (styles set theirs inside).
self.gs = GState::default();
self.pending_unread_fill = false;
self.pending_unread_stroke = false;
Ok(())
}
@@ -244,6 +283,8 @@ impl<'a> Reader<'a> {
let (children, clip) = self.clip_first(f.children, &f.clips);
let art = self.new_node(NodeKind::Group { children, clip }, &f.gs);
self.gs = f.gs;
self.pending_unread_fill = f.pending_unread_fill;
self.pending_unread_stroke = f.pending_unread_stroke;
self.after_object = f.after;
self.mesh_ended = f.mesh_ended;
let Some(Frame { kind: Kind::Obj(o), .. }) = self.frames.last_mut() else {
@@ -252,6 +293,7 @@ impl<'a> Reader<'a> {
if o.art.replace(Arc::new(art)).is_some() {
return Err("a mask has more than one art block".into());
}
o.unread = f.unread;
Ok(())
}
@@ -261,13 +303,15 @@ impl<'a> Reader<'a> {
return Ok(());
}
let Some(f) = self.frames.pop() else { return Ok(()) };
let Frame { kind, children, base, hidden, gs, after, mesh_ended, state, .. } = f;
let Frame { kind, children, base, hidden, gs, pending_unread_fill, pending_unread_stroke, after, mesh_ended, state, .. } = f;
let Kind::Obj(mut o) = kind else { return Ok(()) };
obj::close(&mut o, &mut self.stack, base);
self.obj_hidden = hidden;
// Art made inside a dictionary (type's paths, a mask's art) isn't kept, and doesn't end what
// follows the object the dictionary is about: a mesh's state comes after its mask.
self.gs = gs;
self.pending_unread_fill = pending_unread_fill;
self.pending_unread_stroke = pending_unread_stroke;
if let Some(state) = state {
self.keep_state(state);
}
@@ -295,7 +339,7 @@ impl<'a> Reader<'a> {
}
"Document" => self.document(&o),
"AI11Text" => self.text_slot(&o)?,
"SymbolInstance" => self.unreadable("symbols"),
"SymbolInstance" => self.symbol_instance(&o)?,
"ForeignObject" => self.foreign_art(&o)?,
_ => {}
}
@@ -330,14 +374,95 @@ impl<'a> Reader<'a> {
/// Note something the data has that isn't read: an error on a layer that shows, left out of a
/// layer that doesn't.
pub(super) fn unreadable(&mut self, what: &str) {
if self.shown() {
self.unsupported.insert(what.into());
pub(super) fn unreadable(&mut self, what: &str, hidden: bool) {
if (!hidden || self.in_mask()) && !self.gs.hidden && self.shown() {
if !self.defer_to_mask(what) && !self.mark_unread(what.to_string()) {
self.unsupported.insert(what.into());
}
} else {
self.hidden_unread.insert(what.into());
}
}
/// Keep what an opacity mask's art doesn't read with the mask, until its dictionary says
/// whether the mask is enabled. Whether art of a mask is being read. Operators it doesn't know
/// are kept up to 16, as [`Reader::note_unknown`] keeps them.
fn defer_to_mask(&mut self, what: &str) -> bool {
let Some(f) = self.frames.iter_mut().rev().find(|f| matches!(f.kind, Kind::Mask)) else { return false };
let full = what.starts_with('`') && f.unread.iter().filter(|w| w.starts_with('`')).count() >= 16;
if !full && !f.unread.iter().any(|w| w == what) {
f.unread.push(what.into());
}
true
}
/// Mark the first object container below the innermost layer.
pub(super) fn mark_unread(&mut self, what: String) -> bool {
let Some(layer) = self.frames.iter().rposition(|f| matches!(f.kind, Kind::Layer(_))) else { return false };
let Some(frame) = self.frames.get_mut(layer + 1).filter(|f| matches!(f.kind, Kind::Group | Kind::Compound | Kind::Clip)) else {
return false;
};
if !frame.unread.contains(&what) {
frame.unread.push(what);
}
true
}
/// Stand in for one unread object with a box that the page can match.
pub(super) fn unread_box(&mut self, what: &str, bounds: super::Rect, hidden: bool) -> Result<Option<NodeId>, String> {
if (hidden && !self.in_mask()) || self.gs.hidden || !self.shown() {
self.hidden_unread.insert(what.into());
return Ok(None);
}
// A mask's art isn't an object of the layers: its object, or the file, is what isn't read.
if self.in_mask() {
self.unreadable(what, false);
return Ok(None);
}
let in_container = self
.frames
.iter()
.rposition(|f| matches!(f.kind, Kind::Layer(_)))
.is_some_and(|layer| self.frames.get(layer + 1).is_some_and(|f| matches!(f.kind, Kind::Group | Kind::Compound | Kind::Clip)));
if !super::art::sane(bounds) || bounds.area() <= 0.0 {
self.unsupported.insert(what.into());
return Ok(None);
}
let path = vectorcraft_geom::shapes::rectangle(bounds);
let gs = self.gs.clone();
let node = self.new_node(NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: false, guide: false }, &gs);
let id = node.id;
if in_container {
self.mark_unread(what.into());
}
self.add(node, hidden)?;
if !in_container {
self.unread.push((id, vec![what.into()]));
}
Ok(Some(id))
}
fn symbol_instance(&mut self, o: &Obj) -> Result<(), String> {
let bounds = o.get("SymbolRef").and_then(|v| v.iter().find_map(V::text)).and_then(|name| self.symbol_bounds.get(&name).copied());
let matrix = o
.get("Matrix")
.or_else(|| o.get("Transform"))
.map(|v| v.iter().find_map(|item| if let V::Arr(a) = item { Some(Self::nums(a)) } else { None }).unwrap_or_else(|| Self::nums(v)));
let box_at_canvas = match (bounds, matrix.as_deref()) {
(Some(b), Some([a, c, d, e, x, y])) if [*a, *c, *d, *e, *x, *y].iter().all(|n| n.is_finite()) => {
Some(self.space().transform_rect_bbox(super::Affine::new([*a, *c, *d, *e, *x, *y]).transform_rect_bbox(b)))
}
_ => None,
};
match box_at_canvas {
Some(b) => self.unread_box("symbols", b, self.obj_hidden).map(|_| ()),
None => {
self.unreadable("symbols", self.obj_hidden);
Ok(())
}
}
}
/// Drop the object just read when it is an envelope's own: a group on `%_` lines, before the
/// envelope's dictionary. It holds the mesh that shapes the envelope, or, while the envelope's
/// contents are being edited, those contents (the mesh then is in the dictionary); either way the
@@ -365,10 +490,15 @@ impl<'a> Reader<'a> {
}
/// Do the layers being read show?
fn shown(&self) -> bool {
pub(super) fn shown(&self) -> bool {
self.frames.iter().all(|f| !matches!(&f.kind, Kind::Layer(a) if !a.visible))
}
/// Is the art being read an opacity mask's? It is on `%_` lines and still shapes what shows.
pub(super) fn in_mask(&self) -> bool {
self.frames.iter().any(|f| matches!(f.kind, Kind::Mask))
}
/// A blend's record read: the blend's own art (the group of its key objects and spine, on `%_`
/// lines) and its drawn steps (the group after it) become one live blend, or, where they can't,
/// the drawn steps stay, without the blend's own art drawn over them (see [`super::blend`]).
+69 -7
View File
@@ -1,5 +1,5 @@
use super::art::{four, sane, two};
use super::types::{FLIP, Kind, MAX_HEX, MAX_PATTERNS, MAX_STACK, PatternHead, Place, Raster, SKIPPED, VERSIONED_UNENDED};
use super::types::{FLIP, Kind, MAX_HEX, MAX_PATTERNS, MAX_STACK, MAX_SUBREADERS, PatternHead, Place, Raster, SKIPPED, VERSIONED_UNENDED};
use super::{Arc, Node, PatternDef, Reader, Rect, Tok, V, marker};
impl<'a> Reader<'a> {
@@ -23,7 +23,7 @@ impl<'a> Reader<'a> {
// ---- comments --------------------------------------------------------------------------
pub(super) fn comment(&mut self, c: &[u8]) -> Result<(), String> {
pub(super) fn comment(&mut self, c: &[u8], hidden: bool) -> Result<(), String> {
let line = String::from_utf8_lossy(c);
let line = line.trim_end();
if let Some((len, hex)) = self.blend_record.as_mut() {
@@ -56,6 +56,11 @@ impl<'a> Reader<'a> {
self.define_pattern(section);
return Ok(());
}
if m == "BeginSymbol" {
let section = self.section("EndSymbol")?;
self.symbol_bounds(section);
return Ok(());
}
if m == "BeginArtStyles" {
let section = self.section("EndArtStyles")?;
self.styles = crate::import::ai::style::art_styles(section);
@@ -107,8 +112,8 @@ impl<'a> Reader<'a> {
self.place = Some(Place::default());
} else if line.starts_with("AI5_EndPlace") {
// One that `~` didn't end isn't read.
if self.place.take().is_some() {
self.unreadable("placed files");
if self.place.is_some() {
self.end_place(hidden)?;
}
} else if self.frames.is_empty()
&& let Some(v) = line.strip_prefix("%HiResBoundingBox:")
@@ -185,13 +190,15 @@ impl<'a> Reader<'a> {
/// A pattern's definition (`section`: what follows `%AI3_BeginPattern`), read by a reader of its
/// own into a pattern swatch, its art in the pattern's space (y down). A definition it can't
/// read whole (an operator it doesn't know, type, unbalanced groups, no tile) isn't kept: art
/// read whole (an operator it doesn't know, type, a pattern it doesn't have, unbalanced groups,
/// no tile) isn't kept: art
/// filled with it can't be read.
fn define_pattern(&mut self, section: &[u8]) {
if self.pattern_tiles.len() >= MAX_PATTERNS {
if self.pattern_tiles.len() >= MAX_PATTERNS || self.subreader_depth >= MAX_SUBREADERS {
return;
}
let mut sub = Reader::new(section);
sub.subreader_depth = self.subreader_depth + 1;
sub.pattern = Some(PatternHead::default());
// The gradients defined so far, lent (and those it defines kept).
sub.gradients = std::mem::take(&mut self.gradients);
@@ -200,7 +207,13 @@ impl<'a> Reader<'a> {
let read = sub.open(Kind::Group, false).and_then(|()| sub.run());
self.nodes = sub.nodes;
self.gradients = std::mem::take(&mut sub.gradients);
let whole = read.is_ok() && sub.unsupported.is_empty() && sub.unknown.is_empty() && sub.frames.len() == 1;
// Unread art in the definition, hidden or not, leaves the tile incomplete.
let whole = read.is_ok()
&& sub.unsupported.is_empty()
&& sub.unknown.is_empty()
&& sub.unread.is_empty()
&& sub.hidden_unread.is_empty()
&& sub.frames.len() == 1;
let (Some(head), Some(frame)) = (sub.pattern.take(), sub.frames.pop()) else { return };
let Some(tile) = head.tile.filter(|t| whole && sane(*t) && t.width() > 0.0 && t.height() > 0.0 && !frame.children.is_empty()) else {
return;
@@ -224,6 +237,34 @@ impl<'a> Reader<'a> {
}
}
/// Read a definition's box for an instance whose art must come from the page.
fn symbol_bounds(&mut self, section: &[u8]) {
if self.symbol_bounds.len() >= MAX_PATTERNS || self.subreader_depth >= MAX_SUBREADERS {
return;
}
let mut lex = super::Lexer::new(section);
let name = match lex.next_token().map(|t| t.tok) {
Some(Tok::Str(bytes)) => super::obj::text(&bytes),
_ => return,
};
let mut sub = Reader::new(section);
sub.subreader_depth = self.subreader_depth + 1;
sub.cropmarks = self.cropmarks;
sub.template = self.template;
sub.nodes = self.nodes;
let read = sub.open(Kind::Group, false).and_then(|()| sub.run());
self.nodes = sub.nodes;
if read.is_err() || !sub.unsupported.is_empty() || !sub.unknown.is_empty() || sub.frames.len() != 1 {
return;
}
let Some(frame) = sub.frames.pop() else { return };
let bounds = frame.children.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds()));
let Some(bounds) = bounds.map(|b| sub.space().inverse().transform_rect_bbox(b)) else { return };
if sane(bounds) && bounds.area() > 0.0 {
self.symbol_bounds.insert(name, bounds);
}
}
/// In a pattern's definition, the operands of its first operator start with its head,
/// `(name) llx lly urx ury`: taken off them.
pub(super) fn take_head(&mut self, mut vals: Vec<V>) -> Vec<V> {
@@ -237,3 +278,24 @@ impl<'a> Reader<'a> {
vals.split_off(at + 5)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deeply_nested_symbol_definitions_stop_at_subreader_limit() {
let mut definition = String::from("(Outer) 0 Ae\n");
for i in 0..300 {
definition.push_str(&format!("%AI14_BeginSymbol\n(S{i}) 0 Ae\n"));
}
definition.push_str("0 0 m\n4 0 L\n4 4 L\n0 4 L\nf\n");
for _ in 0..300 {
definition.push_str("%AI10_EndSymbol\n0 0 m\n4 0 L\n4 4 L\n0 4 L\nf\n");
}
let mut reader = Reader::new(b"");
reader.subreader_depth = MAX_SUBREADERS;
reader.symbol_bounds(definition.as_bytes());
assert!(reader.symbol_bounds.is_empty());
}
}
+14 -2
View File
@@ -243,10 +243,20 @@ impl<'a> Reader<'a> {
if let Some(n) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. })) {
n.extras_mut().mask = Some(Box::new(value));
} else if !on("Disabled") {
self.unreadable("opacity masks without an owning object");
self.unreadable("opacity masks without an owning object", false);
}
// What the art doesn't read matters only when the mask shapes what shows.
for what in &mask.unread {
if on("Disabled") {
self.hidden_unread.insert(what.clone());
} else if let Some(op) = what.strip_prefix('`').and_then(|w| w.strip_suffix('`')) {
self.note_unknown(op, false);
} else {
self.unreadable(what, false);
}
}
} else if mask.nums("Disabled").first().copied() != Some(1.0) {
self.unreadable("opacity masks without readable art");
self.unreadable("opacity masks without readable art", false);
}
}
let name = match o.text("AIArtName") {
@@ -453,6 +463,8 @@ impl<'a> Reader<'a> {
artboard,
template: self.template,
hidden_unread: self.hidden_unread,
unread: self.unread,
unread_elsewhere: self.unread_elsewhere,
slot_names: self.slot_names,
restyled: self.restyled,
placed_elsewhere: self.placed_elsewhere,
+22 -4
View File
@@ -80,7 +80,7 @@ impl<'a> Reader<'a> {
// Only `Bh` reads an undefined number. Anywhere else it would reach a point or a size, so
// the operator is one the reader doesn't know, as before.
if w != "Bh" && !self.frames.is_empty() && vals.iter().any(|v| matches!(v, V::Num(n) if n.is_nan())) {
self.note_unknown("--");
self.note_unknown("--", hidden);
return Ok(());
}
if let Some(d) = self.gradient_def.as_mut() {
@@ -303,7 +303,7 @@ impl<'a> Reader<'a> {
_ if IGNORED.contains(&w) => {}
// Before the art, the setup runs procedures of the printing format.
_ if self.frames.is_empty() => {}
_ => self.note_unknown(w),
_ => self.note_unknown(w, hidden),
}
Ok(())
}
@@ -340,22 +340,38 @@ impl<'a> Reader<'a> {
}
let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None });
let Some(tile) = name.as_deref().and_then(|n| self.pattern_tiles.get(n)).copied() else {
self.unreadable("pattern fills");
self.unread_pattern(w);
return;
};
let matrix = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { paint::affine(&Self::nums(a)) } else { None });
let Some(xf) = pattern_xf(self.space(), matrix, tile) else {
self.unreadable("pattern fills");
self.unread_pattern(w);
return;
};
let p = Paint::Pattern { pattern: name.unwrap_or_default(), xf };
if w == "p" {
self.pending_unread_fill = false;
self.gs.fill = p;
} else {
self.pending_unread_stroke = false;
self.gs.stroke = p;
}
}
fn unread_pattern(&mut self, w: &str) {
if self.shown() {
if w == "p" {
self.pending_unread_fill = true;
} else {
self.pending_unread_stroke = true;
}
}
// In a pattern's definition, only art painted with it makes the tile unreadable (see `paint`).
if self.pattern.is_none() && (!self.shown() || self.gs.hidden) {
self.hidden_unread.insert("pattern fills".into());
}
}
fn color(&mut self, w: &str, vals: &[V]) {
let Some((p, named)) = paint::color_op(w, vals) else { return };
// Registration (`Xs`, `Xz`) says nothing of the document's colour model.
@@ -373,9 +389,11 @@ impl<'a> Reader<'a> {
if w.ends_with(|c: char| c.is_ascii_lowercase()) {
self.gs.fill = p;
self.pending_fill = None;
self.pending_unread_fill = false;
} else {
self.gs.stroke = p;
self.pending_stroke = None;
self.pending_unread_stroke = false;
}
}
+8
View File
@@ -3,6 +3,9 @@ use super::{Affine, BlendMode, Dash, FillRule, Knockout, LayerColor, LineCap, Li
/// Deepest nesting of layers, groups and dictionaries.
pub(super) const MAX_DEPTH: usize = 256;
/// Most nested pattern and symbol readers on one call stack.
pub(super) const MAX_SUBREADERS: usize = 32;
/// Most operands on the stack.
pub(super) const MAX_STACK: usize = 1 << 20;
@@ -147,12 +150,17 @@ pub(super) struct Frame {
/// The state when it opened (a container's lock and visibility; a dictionary's state to go
/// back to).
pub(super) gs: GState,
/// Unresolved pattern paint saved by `q`, which restores graphics state at `Q`.
pub(super) pending_unread_fill: bool,
pub(super) pending_unread_stroke: bool,
/// The operand stack's height when it opened.
pub(super) base: usize,
/// The clipping paths' indexes among the children.
pub(super) clips: Vec<usize>,
/// A compound member group explicitly declares its subpaths' winding with `D`.
pub(super) compound_members: bool,
/// Unread kinds belonging to this top-level object.
pub(super) unread: Vec<String>,
/// A dictionary in the art: the transparency (`Xy`) its art set last, which stays after it.
pub(super) state: Option<(f32, BlendMode, bool, Knockout, bool)>,
}
+279 -9
View File
@@ -33,6 +33,13 @@ fn read_ok(body: &str) -> Structure {
}
}
fn assert_unread(r: &Structure, kind: &str) {
assert_eq!(r.unread.len(), 1);
let (id, kinds) = &r.unread[0];
assert_eq!(kinds, &[kind]);
assert!(r.doc.node(*id).is_some(), "the unread object has a placeholder");
}
#[test]
fn ruler_units_from_the_header_set_the_documents_units() {
let body = layer("");
@@ -286,18 +293,41 @@ fn a_pattern_it_cant_read_is_an_error_only_where_it_fills() {
let s = read_ok(&format!("{unread}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n"))));
assert!(s.doc.patterns.is_empty() && s.doc.swatches.iter().all(|w| !matches!(w.paint, Paint::Pattern { .. })));
for name in ["Old", "Odd", "Flat"] {
let e = read(
let r = read(
stream(&format!("{unread}{}", layer(&format!("({name}) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(0.0, 0.0, 1.0, 1.0)))))
.as_bytes(),
)
.unwrap_err();
assert!(e.contains("pattern fills"), "{name}: {e}");
.unwrap();
assert_unread(&r, "pattern fills");
}
// Type in a pattern isn't read either.
let typed = "%AI3_BeginPattern: (Words)\n(Words) 0 0 8 8\n%_/AI11Text :\n%_0 /StoryIndex ,\n%_;\nE\n%AI3_EndPattern\n";
assert!(read_ok(&format!("{typed}{}", layer(""))).doc.patterns.is_empty());
}
/// A pattern whose definition paints with a pattern it doesn't have isn't kept, on `%_` lines or
/// not: a fill with it is something the file has that isn't read.
#[test]
fn a_pattern_painting_with_an_unread_pattern_is_not_kept() {
for prefix in ["%_", ""] {
let tile = rect_path(0.0, 0.0, 8.0, 8.0).replace('\n', &format!("\n{prefix}"));
let def = format!(
"%AI3_BeginPattern: (Nest)\n(Nest) 0 0 8 8\n{prefix}(Missing) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{prefix}{tile}f\nE\n%AI3_EndPattern\n"
);
let s = read_ok(&format!("{def}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n"))));
assert!(s.doc.patterns.is_empty(), "{prefix:?}: {:?}", s.doc.patterns.iter().map(|p| &p.name).collect::<Vec<_>>());
let r = read(
stream(&format!("{def}{}", layer(&format!("(Nest) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(0.0, 0.0, 1.0, 1.0))))).as_bytes(),
)
.unwrap();
assert_unread(&r, "pattern fills");
// A color set before the tile is drawn replaces the missing pattern, and the pattern is kept.
let repainted = def.replace(&format!("{prefix}{tile}f"), &format!("{prefix}0 g\n{prefix}{tile}f"));
let s = read_ok(&format!("{repainted}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n"))));
assert!(s.doc.pattern("Nest").is_some(), "{prefix:?}");
}
}
#[test]
fn compound_paths_and_clipping() {
let art_ = format!(
@@ -982,6 +1012,42 @@ fn a_placed_file_on_commented_lines_is_read() {
assert_eq!((img.get_pixel(0, 0).0, img.get_pixel(1, 0).0), ([255, 0, 0, 255], [0, 127, 0, 128]), "its preview");
}
#[test]
fn hidden_placed_file_ended_by_comment_stays_hidden() {
let mut data = hide_placed(&placed(true, "[5 0 0 -5 0 200]", "/pics/photo.png", None, ""));
let end = data.windows(4).position(|w| w == b"%_~\r").unwrap();
data.drain(end..end + 4);
let r = read(&data).unwrap();
assert!(art(&r.doc).is_empty());
assert!(r.unread.is_empty(), "hidden placement has no shown placeholder");
assert!(r.hidden_unread.contains("placed files"));
}
#[test]
fn previewless_placement_records_box_corner_as_alternative() {
let mut data = placed(true, "[1 0 0 -1 -7970 8070]", "missing.png", None, "");
let at = data.windows(15).position(|w| w == b"%AI5_BeginLayer").unwrap();
data.splice(at..at, *b"%AI3_TemplateBox: 100 50 100 50\n");
let r = read(&data).unwrap();
assert_unread(&r, "placed files");
assert_eq!(r.unread_elsewhere.len(), 1);
assert_eq!(r.unread_elsewhere[0].0, r.unread[0].0);
assert_ne!(r.unread_elsewhere[0].1, r.doc.node(r.unread[0].0).unwrap().visual_bounds().unwrap());
}
/// A previewless locked placement keeps its edit lock on the page-art placeholder.
#[test]
fn previewless_placement_placeholder_inherits_lock() {
let mut data = placed(true, "[5 0 0 -5 0 200]", "missing.png", None, "");
let at = data.windows(15).position(|w| w == b"%AI5_BeginPlace").unwrap();
data.splice(at..at, *b"1 A\n");
let result = read(&data).unwrap();
assert_unread(&result, "placed files");
let node = result.doc.node(result.unread[0].0).unwrap();
assert!(node.locked);
assert!(node.visible);
}
#[test]
fn placed_files_are_linked_images_showing_their_preview() {
// A 2×1 preview (premultiplied ARGB, a padded row) of a file of 4×2 pixels.
@@ -1011,8 +1077,7 @@ fn placed_files_are_linked_images_showing_their_preview() {
let xf = pixels(&r.doc).1;
assert_eq!((xf * Point::ZERO, xf * Point::new(2.0, 1.0)), (Point::new(100.0, 50.0), Point::new(110.0, 30.0)));
// Without a preview it can read (none, cut short, not 32 bits, absurd sizes), without a path or
// a matrix, the file isn't read from its editing data.
// A placed file with a box but no usable preview is marked for page-art replacement.
let cut = &preview[..preview.len() - 6];
let huge = b"70000 70000 280000 32 17 \0\0\0\0";
let overflow = b"1 2 18446744073709551615 32 17 \0\0\0\0\0\0\0\0";
@@ -1027,8 +1092,13 @@ fn placed_files_are_linked_images_showing_their_preview() {
("[0 0 0 0 0 0]", "a.png", Some(&preview[..])),
("[1 2 3]", "a.png", Some(&preview[..])),
] {
let e = read(&placed(true, matrix, path, preview, "")).unwrap_err();
assert!(e.contains("placed files"), "{matrix} {path} {preview:?}: {e}");
let result = read(&placed(true, matrix, path, preview, ""));
if matrix == "[0 0 0 0 0 0]" || matrix == "[1 2 3]" {
let e = result.err().unwrap_or_else(|| panic!("expected missing bounds for {matrix} {path}"));
assert!(e.contains("placed files"), "{matrix} {path} {preview:?}: {e}");
} else {
assert_unread(&result.unwrap(), "placed files");
}
}
// On a layer that doesn't show, such a file is left out of it.
let r = read(&placed(false, "[5 0 0 -5 0 200]", "a.png", None, "")).unwrap();
@@ -1070,8 +1140,8 @@ fn text_objects_are_slots_named_after_their_story() {
fn what_it_doesnt_read_is_an_error_where_it_shows() {
let e = read(stream(&layer("1 2 frobnicate\n")).as_bytes()).unwrap_err();
assert!(e.contains("`frobnicate`"), "{e}");
let e = read(stream(&layer("(Jive) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n")).as_bytes()).unwrap_err();
assert!(e.contains("pattern fills"), "{e}");
let r = read(stream(&layer(&format!("(Jive) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(0.0, 0.0, 1.0, 1.0)))).as_bytes()).unwrap();
assert_unread(&r, "pattern fills");
let e = read(stream(&layer("/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n")).as_bytes()).unwrap_err();
assert!(e.contains("symbols"), "{e}");
// A mask with no owner must still be reported (#2004).
@@ -1093,6 +1163,206 @@ fn what_it_doesnt_read_is_an_error_where_it_shows() {
read_ok(&format!("Adobe_level2_AI5 /initialize get exec\n{}", layer("")));
}
#[test]
fn unresolved_pattern_reset_before_group_path_keeps_live_art() {
for (operator, draw, reset) in [("p", "f", "0 0 0 1 k"), ("P", "S", "0 0 0 1 K")] {
let body = format!("u\n(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n{reset}\n{}{draw}\nU\n", rect_path(0.0, 0.0, 4.0, 4.0),);
let result = read_ok(&layer(&body));
assert!(result.unread.is_empty(), "{operator}: {:?}", result.unread);
let children = art(&result.doc);
assert_eq!(children.len(), 1, "{operator}: group count");
let group_art = children[0].children().expect("group children");
assert_eq!(group_art.len(), 1, "{operator}: live path count");
assert!(matches!(group_art[0].kind, NodeKind::Path { .. }));
}
}
#[test]
fn unresolved_pattern_in_dictionary_does_not_mark_following_path_unread() {
for (operator, draw) in [("p", "f"), ("P", "S")] {
let body = format!("/Unused :\n(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n;\n{}{draw}\n", rect_path(0.0, 0.0, 4.0, 4.0),);
let result = read_ok(&layer(&body));
assert!(result.unread.is_empty(), "{operator}: {:?}", result.unread);
let children = art(&result.doc);
assert_eq!(children.len(), 1, "{operator}: live path count");
assert!(matches!(children[0].kind, NodeKind::Path { .. }));
}
}
#[test]
fn color_in_dictionary_does_not_clear_outer_unresolved_pattern() {
for (operator, draw, color) in [("p", "f", "0 0 0 1 k"), ("P", "S", "0 0 0 1 K")] {
let body = format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n/Unused :\n{color}\n;\n{}{draw}\n", rect_path(0.0, 0.0, 4.0, 4.0),);
let result = read_ok(&layer(&body));
assert_unread(&result, "pattern fills");
assert_eq!(art(&result.doc).len(), 1, "{operator}: unread path placeholder");
}
}
#[test]
fn gradient_instance_after_unresolved_pattern_is_read() {
let defs =
"%AI5_BeginGradient: (G)\n(G) 0 2 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 0 0 2 1 6 50 0 Bs\n%_0 0 0 0 0 0 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
for (operator, flag, draw) in [("p", "", "f"), ("P", "1 ", "S")] {
let body = format!(
"(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n{}{draw}\n{}{flag}Bb\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n10 0 0 10 100 50 Xm\n{draw}\n0 BB\n",
rect_path(0.0, 0.0, 4.0, 4.0),
rect_path(10.0, 0.0, 14.0, 4.0),
);
let result = read(stream(&format!("{defs}{}", layer(&body))).as_bytes()).unwrap();
assert_unread(&result, "pattern fills");
let a = art(&result.doc);
assert_eq!(a.len(), 2, "{operator}");
assert_eq!(result.unread[0].0, a[0].id, "{operator}: only the pattern's path is unread");
}
}
#[test]
fn unresolved_pattern_in_mask_art_stays_unread() {
let mask_path = rect_path(0.0, 0.0, 4.0, 4.0).lines().map(|line| format!("%_{line}\n")).collect::<String>();
let patterned = format!("%_(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{mask_path}%_f\n");
// A placed file with no preview, and a symbol whose definition the file doesn't have.
let placed = "%_%AI5_BeginPlace\n%_%%FileType: 1\n%_[5 0 0 -5 0 200] 10 20 14 22 4 4 0\n%_(missing.png)`\n%_%%IncludeFile:missing.png\n%_~\n%_%AI5_EndPlace\n";
let symbol = "%_/SymbolInstance :\n%_(Absent) /SymbolRef ,\n%_;\n";
for (art_, kind) in [
(patterned.as_str(), "pattern fills"),
("%_/Mesh X!\n%_/End X!\n", "gradient meshes it can't read"),
(placed, "placed files"),
(symbol, "symbols"),
("%_1 2 frobnicate\n", "`frobnicate`"),
] {
let mask = format!(
"%_/ArtDictionary :\n%_/Mask :\n%_X=\n{art_}%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_1 /Bool (Linked) ,\n%_; (AI9_Mask) ,\n%_;\n"
);
let masked = format!("{RED}{}f\n{mask}", rect_path(0.0, 0.0, 4.0, 4.0));
let e = read(stream(&layer(&masked)).as_bytes()).unwrap_err();
assert!(e.contains(kind), "{e}");
// In a group, the group is the unread object.
let result = read_ok(&layer(&format!("u\n{masked}U\n")));
assert_unread(&result, kind);
assert!(result.hidden_unread.is_empty(), "{kind}: {:?}", result.hidden_unread);
// A disabled mask doesn't change how its object looks: what it holds is left out.
let disabled = masked.replace("%_1 /Bool (Linked) ,\n", "%_1 /Bool (Linked) ,\n%_1 /Bool (Disabled) ,\n");
for body in [disabled.clone(), format!("u\n{disabled}U\n")] {
let result = read_ok(&layer(&body));
assert!(result.unread.is_empty(), "{kind}: {:?}", result.unread);
assert!(result.hidden_unread.contains(kind), "{kind}: {:?}", result.hidden_unread);
}
}
// Operators a mask's art doesn't know are kept up to 16.
let ops = (0..40).map(|i| format!("%_op{i}\n")).collect::<String>();
let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{ops}%_X+\n%_(Art) ,\n%_1 /Bool (Linked) ,\n%_; (AI9_Mask) ,\n%_;\n");
let result = read_ok(&layer(&format!("u\n{RED}{}f\n{mask}U\n", rect_path(0.0, 0.0, 4.0, 4.0))));
assert_eq!(result.unread.len(), 1);
assert_eq!(result.unread[0].1.len(), 16);
}
#[test]
fn hidden_unread_art_beside_drawn_look_needs_no_page_replacement() {
let hidden_path = rect_path(0.0, 0.0, 4.0, 4.0).lines().map(|line| format!("%_{line}\n")).collect::<String>();
for (operator, draw, reset) in [("p", "f", "0 0 0 1 k"), ("P", "S", "0 0 0 1 K")] {
let body = format!(
"u\n%_(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n{hidden_path}%_{draw}\n{reset}\n{}{draw}\nU\n",
rect_path(10.0, 0.0, 14.0, 4.0),
);
let result = read_ok(&layer(&body));
assert!(result.unread.is_empty(), "{operator}: {:?}", result.unread);
assert!(result.hidden_unread.contains("pattern fills"));
let children = art(&result.doc);
let group_art = children[0].children().expect("group children");
assert_eq!(group_art.len(), 2);
assert!(matches!(group_art[1].kind, NodeKind::Path { .. }));
}
}
#[test]
fn unknown_operator_on_hidden_line_does_not_mark_drawn_group_unread() {
let body = format!("u\n%_1 2 frobnicate\n{RED}{}f\nU\n", rect_path(10.0, 0.0, 14.0, 4.0));
let result = read_ok(&layer(&body));
assert!(result.unread.is_empty(), "{:?}", result.unread);
assert!(result.hidden_unread.contains("`frobnicate`"));
assert_eq!(art(&result.doc)[0].children().expect("group children").len(), 1);
}
/// A commented closing delimiter does not hide a visible group's unread content.
#[test]
fn visible_group_closed_on_hidden_line_stays_unread() {
let body = format!("u\n1 2 frobnicate\n{RED}{}f\n%_U\n", rect_path(10.0, 0.0, 14.0, 4.0));
let result = read_ok(&layer(&body));
assert_eq!(result.unread.len(), 1);
assert!(result.unread[0].1.contains(&"`frobnicate`".to_string()));
assert!(!result.hidden_unread.contains("`frobnicate`"));
}
/// `Xw` hides unread objects even when their operators are on ordinary lines.
#[test]
fn xw_hidden_unread_keeps_visible_group_sibling() {
for unread in
["1 2 frobnicate\n", "(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n0 0 m\n4 0 L\n4 4 L\nf\n", "/SymbolInstance :\n(Absent) /SymbolRef ,\n;\n"]
{
let body = format!("u\n1 Xw\n{unread}0 Xw\n0 0 0 1 k\n{}f\nU\n", rect_path(10.0, 0.0, 14.0, 4.0));
let result = read_ok(&layer(&body));
assert!(result.unread.is_empty(), "{unread}: {:?}", result.unread);
assert!(!result.hidden_unread.is_empty(), "{unread}");
let children = art(&result.doc);
let group_art = children[0].children().expect("group children");
assert_eq!(group_art.iter().filter(|node| node.visible).count(), 1, "{unread}");
}
let mut data = placed(true, "[5 0 0 -5 0 200]", "missing.png", None, "0 Xw\n");
let at = data.windows(15).position(|w| w == b"%AI5_BeginPlace").unwrap();
data.splice(at..at, *b"1 Xw\n");
let result = read(&data).unwrap();
assert!(result.unread.is_empty());
assert!(result.hidden_unread.contains("placed files"));
}
#[test]
fn hidden_unread_symbol_does_not_mark_drawn_group_unread() {
let body = format!("u\n%_/SymbolInstance :\n%_(Absent) /SymbolRef ,\n%_;\n{RED}{}f\nU\n", rect_path(10.0, 0.0, 14.0, 4.0));
let result = read_ok(&layer(&body));
assert!(result.unread.is_empty(), "{:?}", result.unread);
assert!(result.hidden_unread.contains("symbols"));
assert_eq!(art(&result.doc)[0].children().expect("group children").len(), 1);
}
#[test]
fn unresolved_pattern_paint_persists_past_plain_group_until_replaced() {
for (operator, draw, reset) in [("p", "f", "0 0 0 1 k"), ("P", "S", "0 0 0 1 K")] {
let body = format!(
"u\n(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n{}{draw}\nU\n{}{draw}\n{reset}\n{}{draw}\n",
rect_path(0.0, 0.0, 4.0, 4.0),
rect_path(10.0, 0.0, 14.0, 4.0),
rect_path(20.0, 0.0, 24.0, 4.0),
);
let result = read_ok(&layer(&body));
assert_eq!(result.unread.len(), 2, "{operator}: {:?}", result.unread);
assert_eq!(art(&result.doc).len(), 3);
}
}
#[test]
fn unresolved_pattern_paint_inside_clip_restores_at_q() {
for (operator, draw, reset) in [("p", "f", "0 0 0 1 k"), ("P", "S", "0 0 0 1 K")] {
let body = format!(
"q\n(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n{}{draw}\nQ\n{}{draw}\n",
rect_path(0.0, 0.0, 4.0, 4.0),
rect_path(10.0, 0.0, 14.0, 4.0),
);
let result = read_ok(&layer(&body));
assert_eq!(result.unread.len(), 1, "{operator}: {:?}", result.unread);
assert_eq!(art(&result.doc).len(), 2);
let body = format!(
"(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\nq\n{reset}\n{}{draw}\nQ\n{}{draw}\n",
rect_path(0.0, 0.0, 4.0, 4.0),
rect_path(10.0, 0.0, 14.0, 4.0),
);
let result = read_ok(&layer(&body));
assert_eq!(result.unread.len(), 1, "{operator}: {:?}", result.unread);
assert_eq!(result.unread[0].0, art(&result.doc)[1].id);
}
}
#[test]
fn sections_it_skips() {
let body = "%AI5_Begin_NonPrinting\n%AI14_BeginSymbol\n0 Ae\nu\nfrobnicate\n%AI10_EndSymbol\n%AI3_BeginPattern: (P)\nU\nU\n%AI3_EndPattern\n%AI5_End_NonPrinting--\n\
+2 -2
View File
@@ -90,7 +90,7 @@ mod tests {
ring(&mut layers, Some(MADE), red, 6.0, square);
ring(&mut layers, Some(MADE), red, 2.0, Rect::new(40.0, 40.0, 60.0, 60.0));
let rect = page.artboards[0].rect;
let frame = Frame { page: &page, page_rect: rect, rect, whole: false };
let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] };
let mut view = PageView::new(&frame);
assert_eq!(prune_unseen(&frame, &mut view, &mut layers, false), 1, "only the ring the page doesn't draw");
let widths: Vec<f64> = layers.layers[0].children().unwrap().iter().filter_map(|n| n.appearance.stroke().map(|s| s.width)).collect();
@@ -192,7 +192,7 @@ mod tests {
let a = comparison_render(&mut renderer, &page, rect, 0.4, 1);
let b = comparison_render(&mut vectorcraft_render::Renderer::new(), &vector, rect, 0.4, 1);
assert!(compared(&a, &b, &[], &[]).solid > MAX_SOLID_DIFFERENCE, "the old point sampling falsely refused this page");
let frame = Frame { page: &page, page_rect: rect, rect, whole: false };
let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] };
let mut view = PageView::new(&frame);
assert!(compare(&frame, &mut view, &vector, false).is_ok());
assert!(compare(&frame, &mut view, &Document::new(1000.0, 500.0), false).is_err(), "missing artwork remains an error");
+749 -5
View File
@@ -26,6 +26,8 @@ pub(super) struct Frame<'a> {
/// The page holds all the art that prints (an EPS's page is its art's box, unless it was
/// saved as its artboard): the layers print nothing outside it.
pub(super) whole: bool,
/// The PDF page areas and their moves into the editing document.
pub(super) pages: Vec<(Rect, Affine)>,
}
/// The document of the editing copy `data`, with the text (and strokes round it) of `visible`, the
@@ -57,7 +59,20 @@ pub(super) fn build_from(
trust: bool,
unit: f64,
) -> Result<(Document, Vec<String>), String> {
let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, restyled, placed_elsewhere, warnings } = structure;
let Structure {
mut doc,
to_doc,
bbox,
artboard,
template,
hidden_unread,
unread,
unread_elsewhere,
slot_names,
restyled,
placed_elsewhere,
warnings,
} = structure;
// Editing data in the units of the page's user space ([`in_units`]) is compared with the page
// scaled down to those units, and the document read from it is scaled up by `unit`.
let board = artboard.map(|a| to_doc.transform_rect_bbox(a));
@@ -132,6 +147,15 @@ pub(super) fn build_from(
// goes where the layers have that artboard.
let pages: Vec<Rect> = visible.artboards.iter().map(|a| a.rect).collect();
let boards = page_boards(&doc.artboards, first, &pages);
let page_moves: Vec<(Rect, Affine)> = pages
.iter()
.enumerate()
.map(|(i, page)| {
let move_to_doc =
boards.and_then(|boards| boards.get(i)).map(|board| Affine::translate(board.rect.origin() - page.origin())).unwrap_or(shift);
(*page, move_to_doc)
})
.collect();
// The page with most of an object, the first for what is on none.
let page_of = |object: &[Arc<Node>]| {
extent(object).and_then(|r| {
@@ -167,7 +191,7 @@ pub(super) fn build_from(
a.rect = rect;
}
}
frame = Some(Frame { page: visible, page_rect, rect, whole });
frame = Some(Frame { page: visible, page_rect, rect, whole, pages: page_moves });
}
let mut infos = vec![];
collect_slots(&doc.layers, true, &mut infos);
@@ -180,6 +204,9 @@ pub(super) fn build_from(
if outlined && infos.iter().any(|i| i.shown) {
return Err("its type is to come in as outlines, which only its page has".into());
}
// The page's type inside an unread object's box comes in with the page's art that replaces the
// object (see [`replace_unread`]), not in a text object.
let unread_type = take_unread_type(&doc, frame.as_ref(), &unread, &unread_elsewhere, &mut shown);
// The page's type that needs no text object of the layers: the layers' own legacy text, or
// placed files that show it.
let as_outlines = page_type_elsewhere(infos.is_empty(), infos.iter().all(|i| !i.shown), &mut shown, &mut hidden, &doc, &placed_elsewhere)?;
@@ -214,8 +241,22 @@ pub(super) fn build_from(
let mut made = vec![];
fill_slots(&mut doc, &mut layers, &mut content.into_iter(), &slot_names, &mut empty, &mut made, &mut vec![]);
doc.layers = layers;
// A story in several frames is threaded type: its frames in order (the first holds the story).
made.sort();
let threaded_frames: BTreeSet<NodeId> =
made.chunk_by(|a, b| a.0 == b.0).filter(|thread| thread.len() > 1).flatten().map(|(_, _, id)| *id).collect();
if !unread.is_empty() {
let Some(frame) = frame.as_ref() else { return Err(unread_error(&unread)) };
let mut copied = BTreeSet::new();
let kinds = replace_unread(&mut doc, frame, &unread, &unread_elsewhere, &threaded_frames, &mut copied)?;
// With the text objects filled, the page art chosen can differ from the first try's: the
// page's type set aside for it, and no other, must be in it.
let all_copied = |o: &Vec<Arc<Node>>| !o.is_empty() && o.iter().all(|n| copied.contains(&n.id));
if !unread_type.iter().all(all_copied) || shown.iter().any(all_copied) {
return Err("the page's type in an unread object's box isn't in its page art".into());
}
notes.push(format!("{}: they came in as the page's art", kinds.join(", ")));
}
// A story in several frames is threaded type: its frames in order (the first holds the story).
for thread in made.chunk_by(|a, b| a.0 == b.0) {
if thread.len() > 1 {
doc.text_threads.push(thread.iter().map(|(_, _, id)| *id).collect());
@@ -285,6 +326,406 @@ pub(super) fn build_from(
Ok((doc, notes))
}
/// A visible page node, with its source order and subtree range.
struct PageArt {
node: Arc<Node>,
bounds: Rect,
page: usize,
end: usize,
stateful_ancestor: bool,
}
const ART_MARGIN: f64 = 1.0;
fn inside(art: Rect, bounds: Rect) -> bool {
art.x0 >= bounds.x0 - ART_MARGIN && art.y0 >= bounds.y0 - ART_MARGIN && art.x1 <= bounds.x1 + ART_MARGIN && art.y1 <= bounds.y1 + ART_MARGIN
}
/// Pick the page holding the most of a box. The first page wins a tie.
fn page_for(bounds: Rect, pages: &[Rect]) -> usize {
let mut best = (0, 0.0);
for (i, page) in pages.iter().enumerate() {
let area = page.intersect(bounds).area().max(0.0);
if area > best.1 {
best = (i, area);
}
}
best.0
}
fn collect_live(node: &Node, unread: &BTreeSet<NodeId>, live: &mut Vec<Rect>) {
if !node.visible || unread.contains(&node.id) {
return;
}
if let Some(children) = node.children() {
for child in children {
collect_live(child, unread, live);
}
} else if let Some(bounds) = node.visual_bounds() {
live.push(bounds);
}
}
fn page_node_has_drawing_state(node: &Node) -> bool {
node.clips()
|| matches!(node.kind, NodeKind::Path { clipping: true, .. })
|| node.opacity < 1.0
|| node.blend != super::BlendMode::Normal
|| node.isolate
|| node.knockout != vectorcraft_doc::Knockout::Neutral
|| node.extras().mask.is_some()
|| !node.appearance.items.is_empty()
|| !node.appearance.effects.is_empty()
}
fn index_page(node: &Arc<Node>, pages: &[Rect], moves: &[Affine], art: &mut Vec<PageArt>, stateful_ancestor: bool) {
if !node.visible {
return;
}
let start = art.len();
let mut indexed = false;
if let Some(bounds) = node.visual_bounds() {
let page = page_for(bounds, pages);
if let Some(shift) = moves.get(page) {
art.push(PageArt { node: Arc::clone(node), bounds: shift.transform_rect_bbox(bounds), page, end: start + 1, stateful_ancestor });
indexed = true;
}
}
if let Some(children) = node.children() {
for child in children {
index_page(child, pages, moves, art, stateful_ancestor || page_node_has_drawing_state(node));
}
}
let end = art.len();
if indexed && let Some(entry) = art.get_mut(start) {
entry.end = end;
}
}
/// Node IDs belong to the importing document, not to the pattern's drawing.
fn same_pattern_art(existing: &vectorcraft_doc::PatternDef, source: &vectorcraft_doc::PatternDef) -> bool {
fn clear_ids(node: &mut Node) {
node.id = NodeId(0);
if let Some(children) = node.children_mut() {
for child in children {
clear_ids(Arc::make_mut(child));
}
}
if let Some(mask) = &mut node.extras_mut().mask {
clear_ids(Arc::make_mut(&mut mask.art));
}
}
let mut a = existing.clone();
let mut b = source.clone();
for pattern in [&mut a, &mut b] {
for node in &mut pattern.art {
clear_ids(Arc::make_mut(node));
}
}
a == b
}
/// Bring document resources used by page art across before reassigning its node IDs.
fn copy_page_resources(doc: &mut Document, page: &Document, nodes: &[&Node]) -> Result<(), String> {
fn collect(node: &Node, images: &mut BTreeSet<String>, patterns: &mut BTreeSet<String>, swatches: &mut BTreeSet<String>) {
if let NodeKind::Image(im) = &node.kind {
images.insert(im.key.clone());
}
for item in &node.appearance.items {
match item.paint() {
vectorcraft_color::Paint::Solid { swatch: Some(name), .. } => {
swatches.insert(name.clone());
}
vectorcraft_color::Paint::Gradient(gradient) => {
if let Some(name) = &gradient.swatch {
swatches.insert(name.clone());
}
for stop in &gradient.gradient.stops {
if let Some(name) = &stop.swatch {
swatches.insert(name.clone());
}
}
}
vectorcraft_color::Paint::Pattern { pattern, .. } => {
patterns.insert(pattern.clone());
}
_ => {}
}
}
if let Some(mask) = &node.extras().mask {
collect(&mask.art, images, patterns, swatches);
}
for child in node.children().into_iter().flatten() {
collect(child, images, patterns, swatches);
}
}
let (mut images, mut patterns, mut swatches) = (BTreeSet::new(), BTreeSet::new(), BTreeSet::new());
for node in nodes {
collect(node, &mut images, &mut patterns, &mut swatches);
}
let mut copied = BTreeSet::new();
while let Some(name) = patterns.pop_first() {
if !copied.insert(name.clone()) {
continue;
}
let Some(source) = page.pattern(&name) else { return Err(format!("page art is missing pattern {name}")) };
if let Some(existing) = doc.pattern(&name) {
if !same_pattern_art(existing, source) {
return Err(format!("page art conflicts with pattern {name}"));
}
} else {
for node in &source.art {
collect(node, &mut images, &mut patterns, &mut swatches);
}
let mut pattern = source.clone();
pattern.art = pattern.art.iter().map(|node| Arc::new(reid_page_art(doc, node))).collect();
doc.patterns.push(pattern);
}
}
for key in images {
let Some(blob) = page.images.get(&key) else { return Err(format!("page art is missing image data for {key}")) };
if doc.images.get(&key).is_some_and(|existing| existing.bytes != blob.bytes) {
return Err(format!("page art conflicts with image data for {key}"));
}
doc.images.entry(key).or_insert_with(|| blob.clone());
}
for name in swatches {
let Some(source) = page.swatch(&name) else { return Err(format!("page art is missing swatch {name}")) };
if let Some(existing) = doc.swatch(&name) {
if existing != source {
return Err(format!("page art conflicts with swatch {name}"));
}
} else {
doc.swatches.push(source.clone());
}
}
Ok(())
}
/// The PDF importer keeps group transparency on a group, or folds it into its only child.
/// If the editing object's state cannot be located in that art, use the PDF page as a whole.
/// Every selected page object must carry the state, on itself or down a chain of only children.
fn page_carries_state(editing: &Node, page: &[&Node]) -> bool {
fn carried(node: &Node, matches: &dyn Fn(&Node) -> bool) -> bool {
matches(node)
|| match node.children().map(Vec::as_slice) {
Some([only]) => carried(only, matches),
_ => false,
}
}
let has = |matches: &dyn Fn(&Node) -> bool| !page.is_empty() && page.iter().all(|node| carried(node, matches));
let opacity = editing.opacity >= 0.999 || has(&|node: &Node| (node.opacity - editing.opacity).abs() < 0.001);
let blend = editing.blend == super::BlendMode::Normal || has(&|node: &Node| node.blend == editing.blend);
let isolate = !editing.isolate || has(&|node: &Node| node.isolate);
let knockout = matches!(editing.knockout, vectorcraft_doc::Knockout::Neutral | vectorcraft_doc::Knockout::Off)
|| has(&|node: &Node| node.knockout == editing.knockout);
let appearance = !matches!(editing.kind, NodeKind::Group { .. }) || *editing.appearance == vectorcraft_doc::Appearance::default();
opacity && blend && isolate && knockout && appearance
}
/// The error for unread objects with no page art to use: what isn't read, as the reader names it.
fn unread_error(unread: &[(NodeId, Vec<String>)]) -> String {
let kinds: BTreeSet<&str> = unread.iter().flat_map(|(_, kinds)| kinds.iter().map(String::as_str)).collect();
let (ops, other): (Vec<&str>, Vec<&str>) = kinds.into_iter().partition(|k| k.starts_with('`'));
let mut list: BTreeSet<String> = other.into_iter().map(String::from).collect();
if !ops.is_empty() {
list.insert(format!("operators VectorCraft doesn't know ({})", ops.join(", ")));
}
let list: Vec<&str> = list.iter().map(String::as_str).collect();
format!("it has {}, which VectorCraft doesn't read from it yet", ai::join_and(&list))
}
/// The page's text objects in `shown` that the page objects replacing `doc`'s unread objects hold
/// (see [`replace_unread`], tried on a copy of `doc` before its text objects are filled), taken out
/// of `shown`.
fn take_unread_type(
doc: &Document,
frame: Option<&Frame<'_>>,
unread: &[(NodeId, Vec<String>)],
elsewhere: &[(NodeId, Rect)],
shown: &mut Vec<Vec<Arc<Node>>>,
) -> Vec<Vec<Arc<Node>>> {
let Some(frame) = frame.filter(|_| !unread.is_empty() && !shown.is_empty()) else { return vec![] };
let mut copied = BTreeSet::new();
if replace_unread(&mut doc.clone(), frame, unread, elsewhere, &BTreeSet::new(), &mut copied).is_err() {
return vec![];
}
let (taken, kept) = std::mem::take(shown).into_iter().partition(|o| !o.is_empty() && o.iter().all(|n| copied.contains(&n.id)));
*shown = kept;
taken
}
/// Replace each unread top-level object with painted page objects inside its readable bounds.
/// `copied` gets the ids of the page's nodes copied.
fn replace_unread(
doc: &mut Document,
frame: &Frame<'_>,
unread: &[(NodeId, Vec<String>)],
elsewhere: &[(NodeId, Rect)],
threaded_frames: &BTreeSet<NodeId>,
copied: &mut BTreeSet<NodeId>,
) -> Result<Vec<String>, String> {
fn holds_threaded_frame(node: &Node, frames: &BTreeSet<NodeId>) -> bool {
frames.contains(&node.id) || node.children().is_some_and(|children| children.iter().any(|child| holds_threaded_frame(child, frames)))
}
fn subtree(node: &Node, ids: &mut BTreeSet<NodeId>) {
ids.insert(node.id);
for child in node.children().into_iter().flatten() {
subtree(child, ids);
}
}
let unread_ids: BTreeSet<NodeId> = unread.iter().map(|(id, _)| *id).collect();
let mut live = Vec::new();
for layer in &doc.layers {
collect_live(layer, &unread_ids, &mut live);
}
live.sort_by(|a, b| a.x0.total_cmp(&b.x0));
let source_pages: Vec<Rect> = frame.pages.iter().map(|(page, _)| *page).collect();
let moves: Vec<Affine> = frame.pages.iter().map(|(_, shift)| *shift).collect();
let destination_pages: Vec<Rect> = frame.pages.iter().map(|(page, shift)| shift.transform_rect_bbox(*page)).collect();
let mut art = Vec::new();
for layer in &frame.page.layers {
if layer.visible
&& let Some(children) = layer.children()
{
for child in children {
index_page(child, &source_pages, &moves, &mut art, false);
}
}
}
let mut by_x: Vec<usize> = (0..art.len()).collect();
by_x.sort_by(|a, b| art[*a].bounds.x0.total_cmp(&art[*b].bounds.x0));
let mut claimed = std::collections::BTreeMap::<usize, usize>::new();
let mut counts = std::collections::BTreeMap::<String, usize>::new();
for (id, kinds) in unread {
let Some((bounds, name, visible, locked, editing)) =
doc.node(*id).and_then(|n| n.visual_bounds().map(|b| (b, n.name.clone(), n.visible, n.locked, n.clone())))
else {
return Err("an unread object has no readable bounds".into());
};
if !bounds.area().is_finite() || bounds.area() <= 0.0 {
return Err("an unread object has no readable bounds".into());
}
let mut selected = Vec::new();
let mut page = 0;
let mut first_rejection = None;
'candidate: for candidate in std::iter::once(bounds).chain(elsewhere.iter().filter(|(other, _)| *other == *id).map(|(_, b)| *b)) {
let live_start = live.partition_point(|b| b.x0 < candidate.x0 - ART_MARGIN);
let live_end = live.partition_point(|b| b.x0 <= candidate.x1 + ART_MARGIN);
if live[live_start..live_end].iter().any(|art| inside(*art, candidate)) {
first_rejection.get_or_insert("an unread object's bounds also contain live art");
continue;
}
// Each page has its own copy of art across artboards: one page's art can't stand in for it.
if destination_pages.iter().filter(|page| page.intersect(candidate).area() > 0.0).count() > 1 {
first_rejection.get_or_insert("an unread object lies on more than one artboard");
continue;
}
let candidate_page = page_for(candidate, &destination_pages);
// An artboard that has no page (a PDF part saved with some of the artboards) has no art
// on the page for the object's part on it.
if let Some(page) = destination_pages.get(candidate_page)
&& doc.artboards.iter().map(|a| a.rect.intersect(candidate)).any(|part| part.area() > page.intersect(part).area().max(0.0))
{
first_rejection.get_or_insert("an unread object lies on an artboard that has no page");
continue;
}
let start = by_x.partition_point(|i| art[*i].bounds.x0 < candidate.x0 - ART_MARGIN);
let end = by_x.partition_point(|i| art[*i].bounds.x0 <= candidate.x1 + ART_MARGIN);
let mut candidates: Vec<usize> =
by_x[start..end].iter().copied().filter(|i| art[*i].page == candidate_page && inside(art[*i].bounds, candidate)).collect();
candidates.sort_unstable();
let mut covered_until = 0;
let mut candidate_selected = Vec::new();
for i in candidates {
if i < covered_until || matches!(art[i].node.kind, NodeKind::Path { clipping: true, .. }) {
continue;
}
if art[i].stateful_ancestor {
first_rejection.get_or_insert("an unread object's page art depends on a clipping or appearance ancestor");
continue 'candidate;
}
covered_until = art[i].end;
candidate_selected.push(i);
}
if !candidate_selected.is_empty()
&& !page_carries_state(&editing, &candidate_selected.iter().map(|&i| art[i].node.as_ref()).collect::<Vec<_>>())
{
first_rejection.get_or_insert("an unread object's appearance is not identifiable in the page art");
continue;
}
if candidate_selected.iter().any(|&i| claimed.range(..art[i].end).next_back().is_some_and(|(_, end)| *end > i)) {
first_rejection.get_or_insert("one page object matches more than one unread object");
continue;
}
if !candidate_selected.is_empty() {
selected = candidate_selected;
page = candidate_page;
break;
}
}
if selected.is_empty() {
return Err(first_rejection.unwrap_or("an unread object's page art wasn't found in its bounds").into());
}
if holds_threaded_frame(&editing, threaded_frames) {
return Err("an unread object holds a frame of threaded type".into());
}
copy_page_resources(doc, frame.page, &selected.iter().map(|&i| art[i].node.as_ref()).collect::<Vec<_>>())?;
let mut found = Vec::new();
for &i in &selected {
let mut copy = Node::clone(&art[i].node);
if let Some(shift) = moves.get(page) {
copy.transform(*shift, false);
}
found.push(Arc::new(reid_page_art(doc, &copy)));
claimed.insert(i, art[i].end);
subtree(&art[i].node, copied);
}
let mut replacement = Node::group(*id, found);
replacement.name = name;
replacement.visible = visible;
replacement.locked = locked;
let Some(target) = doc.node_mut(*id) else { return Err("an unread object is missing from its layer".into()) };
*target = replacement;
for kind in kinds {
*counts.entry(kind.clone()).or_default() += 1;
}
}
Ok(counts.into_iter().map(|(kind, count)| format!("{kind} ({count})")).collect())
}
/// Copy page drawing IDs, including mask art outside the ordinary child tree.
fn reid_page_art(doc: &mut Document, source: &Node) -> Node {
let mut copy = doc.reid(source);
if let Some(mask) = &mut copy.extras_mut().mask {
mask.art = Arc::new(reid_page_art(doc, &mask.art));
}
if let Some(children) = copy.children_mut() {
for child in children {
let node = reid_page_art_masks(doc, child);
*child = Arc::new(node);
}
}
copy
}
fn reid_page_art_masks(doc: &mut Document, source: &Node) -> Node {
let mut copy = source.clone();
if let Some(mask) = &mut copy.extras_mut().mask {
mask.art = Arc::new(reid_page_art(doc, &mask.art));
}
if let Some(children) = copy.children_mut() {
for child in children {
*child = Arc::new(reid_page_art_masks(doc, child));
}
}
copy
}
/// What goes into each of `infos`' slots, and whether the type is all made from the file's text
/// document. When the text document makes the type of every text object that shows (and the type
/// isn't asked for as outlines: `own` has the page's text objects that show outside placed files),
@@ -315,18 +756,20 @@ fn slot_content(
/// `doc` with the placed files `placed` lists at their other placement (see
/// [`ai::Structure::placed_elsewhere`]): each image's transform followed by its move. `None` when
/// none is listed.
/// no listed image survives replacement.
fn elsewhere(doc: &Document, placed: &[(NodeId, Affine)]) -> Option<Document> {
if placed.is_empty() {
return None;
}
let mut doc = doc.clone();
let mut moved = false;
for (id, shift) in placed {
if let Some(NodeKind::Image(im)) = doc.node_mut(*id).map(|n| &mut n.kind) {
im.xf *= *shift;
moved = true;
}
}
Some(doc)
moved.then_some(doc)
}
/// The page's type that needs no text object of the layers, taken out of `shown` and `hidden` (the
@@ -599,3 +1042,304 @@ pub(super) fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Doc
}
removed
}
#[cfg(test)]
mod candidate_tests {
use super::*;
use vectorcraft_doc::{ImageBlob, ImageObject};
/// A page image with a different proxy keeps its editing layer and original blob.
#[test]
fn page_image_with_different_proxy_keeps_editing_layer() {
let mut doc = Document::new(100.0, 100.0);
let placeholder_id = doc.alloc_id();
let placeholder = Node::path(placeholder_id, vectorcraft_geom::shapes::rectangle(Rect::new(50.0, 50.0, 54.0, 54.0)), Default::default());
doc.insert(Some(doc.layers[0].id), 0, placeholder).unwrap();
let mut page = Document::new(100.0, 100.0);
let mut editing_blob = ImageBlob::new("image/png", vec![1, 2, 3]);
editing_blob.proxy = Some(Arc::new(vec![4, 5, 6]));
let page_blob = ImageBlob::new("image/png", vec![1, 2, 3]);
let key = editing_blob.content_key();
assert_eq!(key, page_blob.content_key());
doc.images.insert(key.clone(), editing_blob.clone());
page.images.insert(key.clone(), page_blob);
let image = Node::new(
page.alloc_id(),
NodeKind::Image(Box::new(ImageObject {
key: key.clone(),
width: 4,
height: 4,
xf: Affine::translate(Vec2::new(50.0, 50.0)),
link: None,
placement: Default::default(),
pixels: None,
})),
);
page.insert(Some(page.layers[0].id), 0, image).unwrap();
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] };
let kinds = replace_unread(
&mut doc,
&frame,
&[(placeholder_id, vec!["placed files".into()])],
&[(placeholder_id, Rect::new(50.0, 50.0, 54.0, 54.0))],
&BTreeSet::new(),
&mut BTreeSet::new(),
)
.unwrap();
assert_eq!(kinds, ["placed files (1)"]);
let replacement = doc.node(placeholder_id).unwrap();
let [image] = replacement.children().unwrap().as_slice() else { panic!("{replacement:?}") };
assert!(matches!(image.kind, NodeKind::Image(_)));
assert_eq!(doc.images.get(&key), Some(&editing_blob));
}
/// An unread object across two artboards isn't replaced by the art of one page only.
#[test]
fn unread_object_across_two_pages_is_not_replaced() {
let mut doc = Document::new(200.0, 100.0);
let id = doc.alloc_id();
doc.insert(
Some(doc.layers[0].id),
0,
Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(90.0, 10.0, 110.0, 20.0)), Default::default()),
)
.unwrap();
let mut page = Document::new(200.0, 100.0);
for bounds in [Rect::new(91.0, 11.0, 99.0, 19.0), Rect::new(101.0, 11.0, 109.0, 19.0)] {
let art = Node::path(
page.alloc_id(),
vectorcraft_geom::shapes::rectangle(bounds),
vectorcraft_doc::Appearance::basic(
vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK),
vectorcraft_color::Paint::None,
0.0,
),
);
page.insert(Some(page.layers[0].id), page.layers[0].children().unwrap().len(), art).unwrap();
}
let (first, second) = (Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(100.0, 0.0, 200.0, 100.0));
let frame =
Frame { page: &page, page_rect: first, rect: first, whole: false, pages: vec![(first, Affine::IDENTITY), (second, Affine::IDENTITY)] };
let result = replace_unread(&mut doc, &frame, &[(id, vec!["placed files".into()])], &[], &BTreeSet::new(), &mut BTreeSet::new());
assert_eq!(result.unwrap_err(), "an unread object lies on more than one artboard");
}
/// An unread object reaching an artboard that has no page isn't replaced by the page's part of it.
#[test]
fn unread_object_on_an_artboard_without_a_page_is_not_replaced() {
let mut doc = Document::new(100.0, 100.0);
doc.artboards.push(vectorcraft_doc::Artboard { id: 2, rect: Rect::new(100.0, 0.0, 200.0, 100.0), ..doc.artboards[0].clone() });
let id = doc.alloc_id();
doc.insert(
Some(doc.layers[0].id),
0,
Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(90.0, 10.0, 110.0, 20.0)), Default::default()),
)
.unwrap();
let mut page = Document::new(100.0, 100.0);
let art = Node::path(
page.alloc_id(),
vectorcraft_geom::shapes::rectangle(Rect::new(91.0, 11.0, 99.0, 19.0)),
vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), vectorcraft_color::Paint::None, 0.0),
);
page.insert(Some(page.layers[0].id), 0, art).unwrap();
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] };
let result = replace_unread(&mut doc, &frame, &[(id, vec!["placed files".into()])], &[], &BTreeSet::new(), &mut BTreeSet::new());
assert_eq!(result.unwrap_err(), "an unread object lies on an artboard that has no page");
// Half a point onto it is still on it.
let narrow = Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(90.0, 10.0, 100.5, 20.0)), Default::default());
*doc.node_mut(id).unwrap() = narrow;
let result = replace_unread(&mut doc, &frame, &[(id, vec!["placed files".into()])], &[], &BTreeSet::new(), &mut BTreeSet::new());
assert_eq!(result.unwrap_err(), "an unread object lies on an artboard that has no page");
}
/// A nested placeholder's corner cannot replace its readable sibling.
#[test]
fn nested_placeholder_corner_cannot_replace_readable_sibling() {
let mut doc = Document::new(100.0, 100.0);
let group_id = doc.alloc_id();
let placeholder = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), Default::default());
let placeholder_id = placeholder.id;
let sibling = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(70.0, 70.0, 74.0, 74.0)), Default::default());
let sibling_id = sibling.id;
let group = Node::group(group_id, vec![Arc::new(placeholder), Arc::new(sibling)]);
doc.insert(Some(doc.layers[0].id), 0, group).unwrap();
let mut page = Document::new(100.0, 100.0);
let art = Node::path(
page.alloc_id(),
vectorcraft_geom::shapes::rectangle(Rect::new(81.0, 81.0, 89.0, 89.0)),
vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), vectorcraft_color::Paint::None, 0.0),
);
page.insert(Some(page.layers[0].id), 0, art).unwrap();
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] };
let result = replace_unread(
&mut doc,
&frame,
&[(group_id, vec!["placed files".into()])],
&[(placeholder_id, Rect::new(80.0, 80.0, 90.0, 90.0))],
&BTreeSet::new(),
&mut BTreeSet::new(),
);
assert_eq!(result.unwrap_err(), "an unread object's page art wasn't found in its bounds");
let group = doc.node(group_id).unwrap();
assert!(group.children().unwrap().iter().any(|child| child.id == sibling_id));
}
/// An alternative box may carry opacity missing from the first box.
#[test]
fn alternative_box_can_carry_opacity_when_first_box_cannot() {
let mut doc = Document::new(100.0, 100.0);
let id = doc.alloc_id();
let mut editing = Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), Default::default());
editing.opacity = 0.5;
doc.insert(Some(doc.layers[0].id), 0, editing).unwrap();
let mut page = Document::new(100.0, 100.0);
for (bounds, opacity) in [(Rect::new(11.0, 11.0, 19.0, 19.0), 1.0), (Rect::new(51.0, 51.0, 59.0, 59.0), 0.5)] {
let mut art = Node::path(
page.alloc_id(),
vectorcraft_geom::shapes::rectangle(bounds),
vectorcraft_doc::Appearance::basic(
vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK),
vectorcraft_color::Paint::None,
0.0,
),
);
art.opacity = opacity;
page.insert(Some(page.layers[0].id), page.layers[0].children().unwrap().len(), art).unwrap();
}
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] };
let kinds = replace_unread(
&mut doc,
&frame,
&[(id, vec!["placed files".into()])],
&[(id, Rect::new(50.0, 50.0, 60.0, 60.0))],
&BTreeSet::new(),
&mut BTreeSet::new(),
)
.unwrap();
assert_eq!(kinds, ["placed files (1)"]);
let replacement = doc.node(id).unwrap();
let [art] = replacement.children().unwrap().as_slice() else { panic!("{replacement:?}") };
assert_eq!(art.opacity, 0.5);
assert_eq!(art.geometric_bounds(), Some(Rect::new(51.0, 51.0, 59.0, 59.0)));
}
/// Opacity on one of two page objects in the box is not the unread object's opacity.
#[test]
fn opacity_on_one_of_two_page_objects_is_not_identifiable() {
let mut doc = Document::new(100.0, 100.0);
let id = doc.alloc_id();
let mut editing = Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), Default::default());
editing.opacity = 0.5;
doc.insert(Some(doc.layers[0].id), 0, editing).unwrap();
let mut page = Document::new(100.0, 100.0);
for (bounds, opacity) in [(Rect::new(11.0, 11.0, 19.0, 19.0), 0.5), (Rect::new(21.0, 21.0, 39.0, 39.0), 1.0)] {
let mut art = Node::path(
page.alloc_id(),
vectorcraft_geom::shapes::rectangle(bounds),
vectorcraft_doc::Appearance::basic(
vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK),
vectorcraft_color::Paint::None,
0.0,
),
);
art.opacity = opacity;
page.insert(Some(page.layers[0].id), page.layers[0].children().unwrap().len(), art).unwrap();
}
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] };
let result = replace_unread(&mut doc, &frame, &[(id, vec!["placed files".into()])], &[], &BTreeSet::new(), &mut BTreeSet::new());
assert_eq!(result.unwrap_err(), "an unread object's appearance is not identifiable in the page art");
}
/// A claimed first box lets a second unread object use its own corner box.
#[test]
fn second_unread_object_tries_corner_after_first_box_is_claimed() {
let mut doc = Document::new(100.0, 100.0);
let first = doc.alloc_id();
let second = doc.alloc_id();
for (id, bounds) in [(first, Rect::new(10.0, 10.0, 20.0, 20.0)), (second, Rect::new(12.0, 12.0, 22.0, 22.0))] {
let node = Node::path(id, vectorcraft_geom::shapes::rectangle(bounds), Default::default());
doc.insert(Some(doc.layers[0].id), doc.layers[0].children().unwrap().len(), node).unwrap();
}
let mut page = Document::new(100.0, 100.0);
for bounds in [Rect::new(14.0, 14.0, 16.0, 16.0), Rect::new(51.0, 51.0, 59.0, 59.0)] {
let art = Node::path(
page.alloc_id(),
vectorcraft_geom::shapes::rectangle(bounds),
vectorcraft_doc::Appearance::basic(
vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK),
vectorcraft_color::Paint::None,
0.0,
),
);
page.insert(Some(page.layers[0].id), page.layers[0].children().unwrap().len(), art).unwrap();
}
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] };
let unread = [(first, vec!["placed files".into()]), (second, vec!["placed files".into()])];
let mut one_box_doc = doc.clone();
let kinds = replace_unread(&mut doc, &frame, &unread, &[(second, Rect::new(50.0, 50.0, 60.0, 60.0))], &BTreeSet::new(), &mut BTreeSet::new())
.unwrap();
assert_eq!(kinds, ["placed files (2)"]);
let first_art = doc.node(first).unwrap().children().unwrap()[0].geometric_bounds();
let second_art = doc.node(second).unwrap().children().unwrap()[0].geometric_bounds();
assert_eq!(first_art, Some(Rect::new(14.0, 14.0, 16.0, 16.0)));
assert_eq!(second_art, Some(Rect::new(51.0, 51.0, 59.0, 59.0)));
assert_eq!(
replace_unread(&mut one_box_doc, &frame, &unread, &[], &BTreeSet::new(), &mut BTreeSet::new()).unwrap_err(),
"one page object matches more than one unread object"
);
}
/// Only the page's type in the page art that replaces an unread object is taken out of the
/// page's type for the text objects: type in the object's other box, which isn't used, stays.
#[test]
fn only_page_type_in_the_chosen_box_is_taken_for_an_unread_object() {
let mut doc = Document::new(100.0, 100.0);
let id = doc.alloc_id();
doc.insert(
Some(doc.layers[0].id),
0,
Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), Default::default()),
)
.unwrap();
let mut page = Document::new(100.0, 100.0);
let mut nodes = vec![];
for bounds in [Rect::new(12.0, 12.0, 18.0, 18.0), Rect::new(52.0, 52.0, 58.0, 58.0)] {
let art = Node::path(
page.alloc_id(),
vectorcraft_geom::shapes::rectangle(bounds),
vectorcraft_doc::Appearance::basic(
vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK),
vectorcraft_color::Paint::None,
0.0,
),
);
nodes.push(Arc::new(art.clone()));
page.insert(Some(page.layers[0].id), page.layers[0].children().unwrap().len(), art).unwrap();
}
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] };
let unread = [(id, vec!["placed files".into()])];
let mut shown: Vec<Vec<Arc<Node>>> = nodes.iter().map(|n| vec![Arc::clone(n)]).collect();
let taken = take_unread_type(&doc, Some(&frame), &unread, &[(id, Rect::new(50.0, 50.0, 60.0, 60.0))], &mut shown);
assert_eq!(taken.iter().flatten().map(|n| n.id).collect::<Vec<_>>(), [nodes[0].id]);
assert_eq!(shown.iter().flatten().map(|n| n.id).collect::<Vec<_>>(), [nodes[1].id]);
let mut copied = BTreeSet::new();
replace_unread(&mut doc, &frame, &unread, &[], &BTreeSet::new(), &mut copied).unwrap();
assert!(copied.contains(&nodes[0].id) && !copied.contains(&nodes[1].id));
}
}
+1
View File
@@ -24,6 +24,7 @@ pub(super) const LAYERS_UNREAD: &str = "the file's layers weren't read";
pub fn is_loss(note: &str) -> bool {
note.ends_with(TEXT_LEFT_OUT)
|| note.ends_with(TYPE_LEFT_OUT)
|| note.ends_with("they came in as the page's art")
|| note == PAGE_TYPE_LEFT_OUT
|| note == PAGE_TYPE_UNCHECKED
|| [ART_LEFT_OUT, LAYERS_UNREAD, crate::import::graphics::TOO_MUCH, crate::import::graphics::FAR_AWAY].iter().any(|n| note.starts_with(n))
+720
View File
@@ -53,6 +53,10 @@ fn square(x: u32, y: u32) -> String {
format!("0 0 1 0 k\n{x} {y} m\n{} {y} L\n{} {} L\n{x} {} L\nf", x + 4, x + 4, y + 4, y + 4)
}
fn square_path(x: u32, y: u32) -> String {
format!("{x} {y} m\n{} {y} L\n{} {} L\n{x} {} L\nf", x + 4, x + 4, y + 4, y + 4)
}
/// An EPS whose page draws `page` and whose private data is `native`.
fn eps(page: &str, native: &str) -> Vec<u8> {
let stream = ascii85(&zstd(native));
@@ -258,6 +262,667 @@ fn pdf_part(x: f64, y: f64) -> Document {
d
}
fn pdf_two_squares() -> Document {
let mut page = pdf_part(10.0, 86.0);
let other = pdf_part(50.0, 86.0);
let layer = page.layers[0].id;
let mut square = page.reid(&other.layers[0].children().unwrap()[0]);
square.appearance = vectorcraft_doc::Appearance {
items: vec![vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(
vectorcraft_color::Color::cmyk(1.0, 0.0, 0.0, 0.0),
)))],
..Default::default()
}
.into();
page.insert(Some(layer), 1, square).unwrap();
page
}
fn pdf_three_squares() -> Document {
let mut page = pdf_two_squares();
let other = pdf_part(70.0, 86.0);
let layer = page.layers[0].id;
let square = page.reid(&other.layers[0].children().unwrap()[0]);
page.insert(Some(layer), 2, square).unwrap();
page
}
fn pdf_split_square() -> Document {
let mut page = pdf_part(10.0, 86.0);
let layer = page.layers[0].id;
for (i, color) in [vectorcraft_color::Color::cmyk(1.0, 0.0, 0.0, 0.0), vectorcraft_color::Color::cmyk(0.0, 1.0, 0.0, 0.0)].into_iter().enumerate()
{
let x = 50.0 + 2.0 * i as f64;
let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 86.0, x + 2.0, 90.0));
let appearance = vectorcraft_doc::Appearance {
items: vec![vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(color)))],
..Default::default()
};
let node = Node::path(page.alloc_id(), path, appearance);
page.insert(Some(layer), i + 1, node).unwrap();
}
page
}
fn os1_group(body: &str) -> String {
format!("u\n{body}\nU\n")
}
fn replacement_is_cyan(doc: &Document, layer: usize, child: usize) {
let group = &doc.layers[layer].children().unwrap()[child];
let art = &group.children().unwrap()[0];
assert!(matches!(art.kind, NodeKind::Path { .. }));
assert!(matches!(art.appearance.items.first(), Some(vectorcraft_doc::AppearanceItem::Fill(fill))
if fill.paint == vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(1.0, 0.0, 0.0, 0.0))));
}
fn replacement_has_two_halves(doc: &Document) {
let group = &doc.layers[1].children().unwrap()[0];
let art = group.children().unwrap();
assert_eq!(art.len(), 2);
assert_eq!(art[0].geometric_bounds().unwrap().x0, 50.0);
assert_eq!(art[1].geometric_bounds().unwrap().x0, 52.0);
assert!(matches!(art[0].appearance.items.first(), Some(vectorcraft_doc::AppearanceItem::Fill(fill))
if fill.paint == vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(1.0, 0.0, 0.0, 0.0))));
assert!(matches!(art[1].appearance.items.first(), Some(vectorcraft_doc::AppearanceItem::Fill(fill))
if fill.paint == vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 1.0, 0.0, 0.0))));
}
#[test]
fn top_level_symbol_uses_page_art_beside_live_path() {
let symbol = format!("%AI14_BeginSymbol\n(Dot) 0 Ae\n{}\n%AI10_EndSymbol\n", square(0, 0));
let instance = "/SymbolInstance :\n(Dot) /SymbolRef ,\n[1 0 0 1 50 10] /Matrix ,\n;";
let art = native(&(symbol + &layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &format!("{instance}\n{}", square(70, 10)))));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_three_squares(), vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Mixed"]);
assert_eq!(kinds(&doc.layers[1]), ["group[1]", "path"]);
replacement_is_cyan(&doc, 1, 0);
assert!(notes.iter().any(|n| n.contains("symbols (1)")), "{notes:?}");
}
#[test]
fn group_with_only_symbol_instance_uses_page_art() {
let symbol = format!("%AI14_BeginSymbol\n(Dot) 0 Ae\n{}\n%AI10_EndSymbol\n", square(0, 0));
let instance = "/SymbolInstance :\n(Dot) /SymbolRef ,\n[1 0 0 1 50 10] /Matrix ,\n;";
let art = native(&(symbol + &layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &os1_group(instance))));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_two_squares(), vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Mixed"]);
assert_eq!(kinds(&doc.layers[1]), ["group[1]"]);
replacement_is_cyan(&doc, 1, 0);
assert!(notes.iter().any(|n| n.contains("symbols (1)")), "{notes:?}");
}
#[test]
fn unread_group_uses_page_opacity_once() {
let symbol = format!("%AI14_BeginSymbol\n(Dot) 0 Ae\n{}\n%AI10_EndSymbol\n", square(0, 0));
let instance = "/SymbolInstance :\n(Dot) /SymbolRef ,\n[1 0 0 1 50 10] /Matrix ,\n;";
let group = format!("0 Ae\nu\n{instance}\nU\n0 0.5 0 0 0 Xy\n0 0 Xd\n6 () XW");
let art = native(&(symbol + &layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &group)));
let mut page = pdf_two_squares();
let id = page.layers[0].children().unwrap()[1].id;
page.node_mut(id).unwrap().opacity = 0.5;
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Mixed"]);
let replacement = &doc.layers[1].children().unwrap()[0];
assert_eq!(replacement.opacity, 1.0);
assert_eq!(replacement.children().unwrap()[0].opacity, 0.5);
let rendered = vectorcraft_render::Renderer::new().render_region(&doc, vectorcraft_geom::Rect::new(50.0, 86.0, 54.0, 90.0), 4.0, false);
let pixel = rendered.pixels.as_chunks::<4>().0.get(8 * rendered.width as usize + 8).unwrap();
assert!((120..=136).contains(&pixel[3]), "the final document draws the group at 50% once: {pixel:?}");
}
#[test]
fn unread_group_with_unidentified_transparency_uses_page_document() {
let symbol = format!("%AI14_BeginSymbol\n(Dot) 0 Ae\n{}\n%AI10_EndSymbol\n", square(0, 0));
let instance = "/SymbolInstance :\n(Dot) /SymbolRef ,\n[1 0 0 1 50 10] /Matrix ,\n;";
let group = format!("0 Ae\nu\n{instance}\nU\n0 0.5 0 0 0 Xy\n0 0 Xd\n6 () XW");
let art = native(&(symbol + &layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &group)));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_two_squares(), vec![], false, false, 1.0);
assert!(!read, "{notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
assert!(notes.iter().any(|n| n.contains("appearance is not identifiable")), "{notes:?}");
}
#[test]
fn group_with_only_placed_file_without_preview_uses_page_art() {
let placed = "%AI5_BeginPlace\n[1 0 0 1 0 0] 50 10 54 14 (missing) `\n~\n%AI5_EndPlace";
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &os1_group(placed))));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_two_squares(), vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Mixed"]);
assert_eq!(kinds(&doc.layers[1]), ["group[1]"]);
replacement_is_cyan(&doc, 1, 0);
assert!(notes.iter().any(|n| n.contains("placed files (1)")), "{notes:?}");
}
#[test]
fn top_level_pattern_uses_page_art_beside_live_path() {
let body = format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}\n{}", square_path(50, 10), square(70, 10));
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &body)));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_three_squares(), vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Mixed"]);
assert_eq!(kinds(&doc.layers[1]), ["group[1]", "path"]);
replacement_is_cyan(&doc, 1, 0);
assert!(notes.iter().any(|n| n.contains("pattern fills (1)")), "{notes:?}");
}
#[test]
fn pattern_set_in_plain_group_replaces_later_sibling_from_page_art() {
let group = os1_group(&format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}", square_path(50, 10)));
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &format!("{group}\n{}", square_path(70, 10)))));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_three_squares(), vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Mixed"]);
assert_eq!(kinds(&doc.layers[1]), ["group[1]", "group[1]"]);
replacement_is_cyan(&doc, 1, 0);
assert!(notes.iter().any(|n| n.contains("pattern fills (2)")), "{notes:?}");
}
#[test]
fn top_level_placed_file_without_preview_uses_page_art() {
let placed = "%AI5_BeginPlace\n[1 0 0 1 0 0] 50 10 54 14 (missing) `\n~\n%AI5_EndPlace";
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &format!("{placed}\n{}", square(70, 10)))));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_three_squares(), vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Mixed"]);
assert_eq!(kinds(&doc.layers[1]), ["group[1]", "path"]);
replacement_is_cyan(&doc, 1, 0);
assert!(notes.iter().any(|n| n.contains("placed files (1)")), "{notes:?}");
}
#[test]
fn nested_previewless_placed_file_at_box_corner_falls_back() {
let place = "%AI5_BeginPlace\n[1 0 0 -1 -7970 8070] 8000 8000 8040 8040 4 4 0\n(missing.png)`\n~\n%AI5_EndPlace";
let art = native(&layer("Placed", true, &os1_group(place))).replace("%AI5_NumLayers", "%AI3_TemplateBox: 100 50 100 50\n%AI5_NumLayers");
let mut page = Document::new(100.0, 100.0);
let id = page.alloc_id();
let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(30.0, 30.0, 70.0, 70.0));
let fill = vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::gray(0.5)));
page.insert(
Some(page.layers[0].id),
0,
Node::path(id, path, vectorcraft_doc::Appearance { items: vec![vectorcraft_doc::AppearanceItem::Fill(fill)], ..Default::default() }),
)
.unwrap();
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(!read, "a nested placeholder has no group-wide corner alternative: {notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
}
#[test]
fn group_with_previewless_placed_file_and_readable_sibling_keeps_sibling() {
let place = "%AI5_BeginPlace\n[1 0 0 -1 -7970 8070] 8000 8000 8040 8040 4 4 0\n(missing.png)`\n~\n%AI5_EndPlace";
let group = os1_group(&format!("{place}\n{}", square(70, 10)));
let art = native(&layer("Mixed", true, &group)).replace("%AI5_NumLayers", "%AI3_TemplateBox: 100 50 100 50\n%AI5_NumLayers");
let mut page = pdf_part(70.0, 86.0);
let id = page.alloc_id();
let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(30.0, 30.0, 70.0, 70.0));
let fill = vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::gray(0.5)));
page.insert(
Some(page.layers[0].id),
1,
Node::path(id, path, vectorcraft_doc::Appearance { items: vec![vectorcraft_doc::AppearanceItem::Fill(fill)], ..Default::default() }),
)
.unwrap();
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
if read {
let mixed = doc.layers.iter().find(|layer| layer.name.as_deref() == Some("Mixed")).unwrap();
assert!(
mixed.children().unwrap().iter().any(|child| {
child.children().is_some_and(|children| children.iter().any(|n| n.geometric_bounds().is_some_and(|b| b.x0 == 70.0)))
}),
"readable sibling was dropped: {notes:?}"
);
} else {
assert_eq!(names(&doc), ["Layer 1"], "{notes:?}");
}
}
#[test]
fn previewless_placed_file_tries_corner_when_first_box_contains_live_art() {
let place = "%AI5_BeginPlace\n[1 0 0 -1 -7970 8070] 8000 8000 8040 8040 4 4 0\n(missing.png)`\n~\n%AI5_EndPlace";
let live = "0 0 1 0 k\n130 -180 m\n134 -180 L\n134 -184 L\n130 -184 L\nf";
let art = native(&(layer("Live", true, live) + &layer("Placed", true, place)))
.replace("%AI5_NumLayers", "%AI3_TemplateBox: 100 50 100 50\n%AI5_NumLayers");
let mut page = Document::new(100.0, 100.0);
let id = page.alloc_id();
let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(30.0, 30.0, 70.0, 70.0));
let fill = vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::gray(0.5)));
page.insert(
Some(page.layers[0].id),
0,
Node::path(id, path, vectorcraft_doc::Appearance { items: vec![vectorcraft_doc::AppearanceItem::Fill(fill)], ..Default::default() }),
)
.unwrap();
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Placed"]);
assert_eq!(kinds(&doc.layers[0]), ["path"]);
assert_eq!(kinds(&doc.layers[1]), ["group[1]"]);
assert!(notes.iter().any(|note| note.contains("placed files (1)")), "{notes:?}");
}
#[test]
fn replaced_page_art_reids_opacity_mask_art() {
let place = "%AI5_BeginPlace\n[1 0 0 1 0 0] 50 10 54 14 (missing) `\n~\n%AI5_EndPlace";
let art = native(&layer("Placed", true, place));
let mut page = pdf_part(50.0, 86.0);
let page_node_id = page.layers[0].children().unwrap()[0].id;
let mask_id = page.alloc_id();
let mask_path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(50.0, 86.0, 54.0, 90.0));
let mask_fill = vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::WHITE));
let mask_art = Node::path(
mask_id,
mask_path,
vectorcraft_doc::Appearance { items: vec![vectorcraft_doc::AppearanceItem::Fill(mask_fill)], ..Default::default() },
);
page.node_mut(page_node_id).unwrap().extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(mask_art, true)));
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(read, "{notes:?}");
let replacement = &doc.layers[0].children().unwrap()[0].children().unwrap()[0];
let mask = replacement.extras().mask.as_ref().unwrap();
assert_ne!(mask.art.id, mask_id);
assert_ne!(mask.art.id, replacement.id);
assert!(mask.art.id.0 < doc.peek_next_id());
}
#[test]
fn top_level_placed_file_replacement_keeps_page_image_data() {
use vectorcraft_doc::{ImageBlob, ImageObject};
let placed = "%AI5_BeginPlace\n[1 0 0 1 0 0] 50 10 54 14 (missing) `\n~\n%AI5_EndPlace";
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, placed)));
let mut page = pdf_part(10.0, 86.0);
let pixels = image::RgbaImage::from_pixel(4, 4, image::Rgba([0, 255, 255, 255]));
let mut png = Vec::new();
pixels.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let blob = ImageBlob::new("image/png", png);
let key = blob.content_key();
let expected = blob.bytes.clone();
page.images.insert(key.clone(), blob);
let image = Node::new(
page.alloc_id(),
NodeKind::Image(Box::new(ImageObject {
key: key.clone(),
width: 4,
height: 4,
xf: vectorcraft_geom::Affine::translate(vectorcraft_geom::Vec2::new(50.0, 86.0)),
link: None,
placement: Default::default(),
pixels: None,
})),
);
page.insert(Some(page.layers[0].id), 1, image).unwrap();
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Mixed"]);
let replacement = &doc.layers[1].children().unwrap()[0];
let [image] = replacement.children().unwrap().as_slice() else { panic!("{replacement:?}") };
let NodeKind::Image(im) = &image.kind else { panic!("{image:?}") };
assert!(im.pixels.is_none(), "PDF-imported images use document data");
assert_eq!(doc.images.get(&im.key).map(|blob| &blob.bytes), Some(&expected));
assert!(notes.iter().any(|note| note.contains("placed files (1)")), "{notes:?}");
}
#[test]
fn top_level_pattern_replacement_keeps_gradient_stop_swatch() {
use vectorcraft_color::gradient::{GradientKind, GradientPaint, GradientStop};
use vectorcraft_color::{Color, Gradient, Paint, Swatch};
let body = format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}", square_path(50, 10));
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &body)));
let mut page = pdf_two_squares();
let cyan = Color::cmyk(1.0, 0.0, 0.0, 0.0);
page.swatches.push(Swatch { name: "Linked Cyan".into(), paint: Paint::solid(cyan), global: true, spot: false });
let mut stop = GradientStop::new(0.0, cyan);
stop.swatch = Some("Linked Cyan".into());
let gradient = Gradient::new(GradientKind::Linear, vec![stop, GradientStop::new(1.0, cyan)]);
let id = page.layers[0].children().unwrap()[1].id;
let node = page.node_mut(id).unwrap();
node.appearance = vectorcraft_doc::Appearance {
items: vec![vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(Paint::Gradient(Box::new(GradientPaint::new(gradient)))))],
..Default::default()
}
.into();
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(read, "{notes:?}");
let group = &doc.layers[1].children().unwrap()[0];
let replacement = &group.children().unwrap()[0];
assert!(matches!(replacement.appearance.fill().map(|f| &f.paint), Some(Paint::Gradient(g))
if g.gradient.stops[0].swatch.as_deref() == Some("Linked Cyan")));
assert!(matches!(doc.swatch("Linked Cyan"), Some(s) if s.global && s.paint == Paint::solid(cyan)));
}
/// Two independently imported copies of the same tile have different node IDs.
fn page_with_named_pattern(native_art: &str, different: bool) -> Document {
use vectorcraft_color::Paint;
let (editing, _) = crate::ai_alone(&private(&native_art.replace("(Absent)", "(Dots)"))).unwrap();
let source = editing.pattern("Dots").unwrap();
let mut page = pdf_two_squares();
let mut pattern = source.clone();
pattern.art = source.art.iter().map(|node| std::sync::Arc::new(page.reid(node))).collect();
if different {
pattern.tile.x1 += 1.0;
}
page.patterns.push(pattern);
let id = page.layers[0].children().unwrap()[1].id;
let node = page.node_mut(id).unwrap();
node.appearance = vectorcraft_doc::Appearance {
items: vec![vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(Paint::Pattern {
pattern: "Dots".into(),
xf: vectorcraft_geom::Affine::IDENTITY,
}))],
..Default::default()
}
.into();
page
}
fn named_pattern_replacement_art() -> String {
let dots = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 4 4\n%_0 0 0 1 k\n%_1 1 m\n%_3 1 L\n%_3 3 L\n%_1 3 L\n%_f\nE\n%AI3_EndPattern\n";
let unread = format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}", square_path(50, 10));
native(&format!("{dots}{}{}", layer("Live", true, &square(10, 10)), layer("Mixed", true, &unread)))
}
#[test]
fn same_named_pattern_in_page_art_keeps_editing_layers() {
let art = named_pattern_replacement_art();
let page = page_with_named_pattern(&art, false);
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Mixed"]);
assert_eq!(doc.patterns.iter().filter(|p| p.name == "Dots").count(), 1);
let replacement = &doc.layers[1].children().unwrap()[0].children().unwrap()[0];
assert!(matches!(replacement.appearance.fill().map(|f| &f.paint), Some(vectorcraft_color::Paint::Pattern { pattern, .. }) if pattern == "Dots"));
}
#[test]
fn copied_page_pattern_reids_its_mask_art() {
let source_art = named_pattern_replacement_art();
let mut page = page_with_named_pattern(&source_art, false);
let mask_id = page.alloc_id();
let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 4.0, 4.0));
let fill = vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::WHITE));
let mask_art =
Node::path(mask_id, path, vectorcraft_doc::Appearance { items: vec![vectorcraft_doc::AppearanceItem::Fill(fill)], ..Default::default() });
std::sync::Arc::make_mut(&mut page.patterns[0].art[0]).extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(mask_art, true)));
let art = native(&format!(
"{}{}",
layer("Live", true, &square(10, 10)),
layer("Mixed", true, &format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}", square_path(50, 10)))
));
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(read, "{notes:?}");
let mask = doc.pattern("Dots").unwrap().art[0].extras().mask.as_ref().unwrap();
assert_ne!(mask.art.id, mask_id);
assert!(mask.art.id.0 < doc.peek_next_id());
}
#[test]
fn different_named_pattern_in_page_art_falls_back() {
let art = named_pattern_replacement_art();
let page = page_with_named_pattern(&art, true);
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(!read, "{notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
assert!(notes.iter().any(|n| n.contains("page art conflicts with pattern Dots")), "{notes:?}");
}
#[test]
fn partly_overlapping_page_art_does_not_replace_an_object() {
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &os1_group(&format!("{}\nZZ", square(50, 10))))));
let mut page = pdf_part(10.0, 86.0);
let other = pdf_part(48.0, 86.0);
let id = page.layers[0].id;
let square = page.reid(&other.layers[0].children().unwrap()[0]);
page.insert(Some(id), 1, square).unwrap();
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(!read, "{notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
assert!(notes.iter().any(|n| n.contains("page art wasn't found")), "{notes:?}");
}
/// Page art for the unread square and its neighbor under one PDF group.
fn grouped_page_across_unread_and_neighbor(clipped: bool) -> Document {
let mut page = pdf_two_squares();
let layer = page.layers[0].id;
let unread = page.remove(page.layers[0].children().unwrap()[1].id).unwrap();
let other = pdf_part(70.0, 86.0);
let neighbor = page.reid(&other.layers[0].children().unwrap()[0]);
let mut children = Vec::new();
if clipped {
let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(50.0, 86.0, 74.0, 90.0));
let mut clip = Node::path(page.alloc_id(), path, vectorcraft_doc::Appearance::default());
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
children.push(std::sync::Arc::new(clip));
}
children.extend([unread, std::sync::Arc::new(neighbor)]);
let mut group = Node::group(page.alloc_id(), children);
group.set_clips(clipped);
page.insert(Some(layer), 1, group).unwrap();
page
}
#[test]
fn clipped_pdf_group_across_unread_and_neighbor_falls_back() {
let body = format!("{}\n{}", os1_group(&format!("{}\nZZ", square(50, 10))), square(70, 10));
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &body)));
let (doc, notes, read) = crate::layered_ai(&private(&art), grouped_page_across_unread_and_neighbor(true), vec![], false, false, 1.0);
assert!(!read, "{notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
assert!(notes.iter().any(|n| n.contains("clipping or appearance ancestor")), "{notes:?}");
}
#[test]
fn plain_pdf_group_across_unread_and_neighbor_uses_child() {
let body = format!("{}\n{}", os1_group(&format!("{}\nZZ", square(50, 10))), square(70, 10));
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &body)));
let (doc, notes, read) = crate::layered_ai(&private(&art), grouped_page_across_unread_and_neighbor(false), vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Mixed"]);
replacement_is_cyan(&doc, 1, 0);
assert!(notes.iter().any(|n| n.contains("`ZZ` (1)")), "{notes:?}");
}
#[test]
fn live_art_inside_unread_bounds_falls_back_instead_of_drawing_twice() {
let body = format!("{}\n{}", os1_group(&format!("{}\nZZ", square(50, 10))), "0 0 1 0 k\n51 11 m\n53 11 L\n53 13 L\n51 13 L\nf");
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &body)));
let mut page = pdf_two_squares();
let inner = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(51.0, 87.0, 53.0, 89.0));
let appearance = (*page.layers[0].children().unwrap()[0].appearance).clone();
let node = Node::path(page.alloc_id(), inner, appearance);
let layer_id = page.layers[0].id;
page.insert(Some(layer_id), 2, node).unwrap();
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(!read, "{notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
assert!(notes.iter().any(|n| n.contains("bounds also contain live art")), "{notes:?}");
}
#[test]
fn live_art_crossing_unread_bounds_by_half_a_point_falls_back() {
let neighbor = "0 0 1 0 k\n53.5 11 m\n54.5 11 L\n54.5 13 L\n53.5 13 L\nf";
let art = native(&layer("Mixed", true, &format!("{}\n{neighbor}", os1_group(&format!("{}\nZZ", square(50, 10))))));
let mut page = pdf_part(50.0, 86.0);
let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(53.5, 87.0, 54.5, 89.0));
let appearance = (*page.layers[0].children().unwrap()[0].appearance).clone();
let node = Node::path(page.alloc_id(), path, appearance);
let layer = page.layers[0].id;
page.insert(Some(layer), 1, node).unwrap();
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(!read, "{notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
assert!(notes.iter().any(|n| n.contains("bounds also contain live art")), "{notes:?}");
}
#[test]
fn overlapping_unread_objects_cannot_claim_the_same_page_art() {
let first = os1_group(&format!("{}\nZZ", square(50, 10)));
let second = os1_group(&format!("{}\nZZ", square(52, 10)));
let art = native(&layer("Mixed", true, &format!("{first}\n{second}")));
let mut page = pdf_part(52.0, 86.0);
let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(52.0, 86.0, 54.0, 90.0));
let node = page.layers[0].children().unwrap()[0].as_ref().clone();
let layer = page.layers[0].id;
page.remove(node.id).unwrap();
let replacement = Node::path(page.alloc_id(), path, (*node.appearance).clone());
page.insert(Some(layer), 0, replacement).unwrap();
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(!read, "{notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
assert!(notes.iter().any(|n| n.contains("more than one unread object")), "{notes:?}");
}
#[test]
fn top_level_pattern_without_page_art_falls_back() {
let body = format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}", square_path(50, 10));
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &body)));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0);
assert!(!read, "{notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
assert!(notes.iter().any(|n| n.contains("page art wasn't found")), "{notes:?}");
}
#[test]
fn top_level_placed_file_without_page_art_falls_back() {
let placed = "%AI5_BeginPlace\n[1 0 0 1 0 0] 50 10 54 14 (missing) `\n~\n%AI5_EndPlace";
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, placed)));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0);
assert!(!read, "{notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
assert!(notes.iter().any(|n| n.contains("page art wasn't found")), "{notes:?}");
}
#[test]
fn unread_object_on_second_artboard_takes_second_page_art() {
let boards = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\
%_/Dictionary :\n%_0 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
%_/Dictionary :\n%_0 -50 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 -150 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n";
let second = "u\n0 0 1 0 k\n50 -100 m\n54 -100 L\n54 -96 L\n50 -96 L\nf\nZZ\nU";
let art = native(&(boards.to_string() + &layer("Live", true, &square(10, 10)) + &layer("Mixed", true, second)));
let mut page = pdf_part(10.0, 86.0);
let mut board = page.artboards[0].clone();
(board.id, board.rect) = (2, vectorcraft_geom::Rect::new(120.0, 0.0, 220.0, 100.0));
page.artboards.push(board);
let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(170.0, 46.0, 174.0, 50.0));
let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(1.0, 0.0, 0.0, 0.0));
let appearance = vectorcraft_doc::Appearance {
items: vec![vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(paint))],
..Default::default()
};
let node = Node::path(page.alloc_id(), path, appearance);
let layer = page.layers[0].id;
page.insert(Some(layer), 1, node).unwrap();
let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Mixed"]);
replacement_is_cyan(&doc, 1, 0);
let bounds = doc.layers[1].children().unwrap()[0].visual_bounds().unwrap();
assert_eq!(bounds, vectorcraft_geom::Rect::new(50.0, 196.0, 54.0, 200.0));
}
#[test]
fn unknown_operator_between_objects_falls_back() {
let body = format!("{}\nZZ\n{}", square(50, 10), square(70, 10));
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &body)));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_three_squares(), vec![], false, false, 1.0);
assert!(!read, "{notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
assert!(notes.iter().any(|n| n.contains("`ZZ`")), "{notes:?}");
}
#[test]
fn unread_object_without_page_art_names_what_is_unread() {
let art = native(&layer("Mixed", true, &os1_group(&format!("{}\nZZ", square(50, 10)))));
let e = crate::ai_alone(&private(&art)).unwrap_err();
assert_eq!(e, "it has operators VectorCraft doesn't know (`ZZ`), which VectorCraft doesn't read from it yet");
let pattern = os1_group(&format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}", square_path(70, 10)));
let art = native(&layer("Mixed", true, &format!("{}\n{pattern}", os1_group(&format!("{}\nZZ", square(50, 10))))));
let e = crate::ai_alone(&private(&art)).unwrap_err();
assert_eq!(e, "it has operators VectorCraft doesn't know (`ZZ`) and pattern fills, which VectorCraft doesn't read from it yet");
}
#[test]
fn unread_pattern_inside_one_object_uses_page_art_and_keeps_layers() {
let art = native(
&(layer("Live", true, &square(10, 10))
+ &layer(
"Mixed",
true,
&format!("{}\n{}", os1_group(&format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}", square_path(50, 10))), square(70, 10)),
)),
);
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_three_squares(), vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Mixed"]);
assert_eq!(kinds(&doc.layers[0]), ["path"]);
assert_eq!(kinds(&doc.layers[1]), ["group[1]", "path"]);
replacement_is_cyan(&doc, 1, 0);
assert!(notes.iter().any(|n| n.contains("pattern fills (1)") && n.contains("page's art")), "{notes:?}");
}
#[test]
fn unknown_operator_inside_group_uses_page_art() {
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &os1_group(&format!("{}\nZZ", square(50, 10))))));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_split_square(), vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Mixed"]);
assert_eq!(kinds(&doc.layers[1]), ["group[2]"]);
replacement_has_two_halves(&doc);
assert!(notes.iter().any(|n| n.contains("`ZZ` (1)")), "{notes:?}");
}
#[test]
fn unread_object_on_hidden_layer_stays_left_out() {
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Hidden", false, &os1_group(&format!("{}\nZZ", square(50, 10))))));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Live", "Hidden"]);
assert!(notes.iter().any(|n| n.contains("left out of them")), "{notes:?}");
}
#[test]
fn unread_object_without_page_art_falls_back() {
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &os1_group(&format!("{}\nZZ", square(50, 10))))));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0);
assert!(!read, "{notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
assert!(notes.iter().any(|n| n.contains("page art wasn't found")), "{notes:?}");
}
#[test]
fn unclosed_unread_group_falls_back() {
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &format!("u\n{}\nZZ", square(50, 10)))));
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_two_squares(), vec![], false, false, 1.0);
assert!(!read, "{notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
assert!(notes.iter().any(|n| n.contains("closes something it didn't open")), "{notes:?}");
}
#[test]
fn eps_unread_group_uses_printed_page_art() {
let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &os1_group(&format!("{}\nZZ", square(50, 10))))));
let page = format!(
"{}\n1 0 0 0 setcmykcolor 50 10 moveto 52 10 lineto 52 14 lineto 50 14 lineto closepath fill\n0 1 0 0 setcmykcolor 52 10 moveto 54 10 lineto 54 14 lineto 52 14 lineto closepath fill",
page_square(10, 10)
);
let result = import(&eps(&page, &art)).unwrap();
assert_eq!(names(&result.document), ["Live", "Mixed"], "{:?}", result.warnings);
replacement_has_two_halves(&result.document);
assert!(result.warnings.iter().any(|n| n.contains("`ZZ` (1)")), "{:?}", result.warnings);
}
/// The PDF part `d` as read in points from pages of `k` points a unit (their `UserUnit`): its
/// pages and art `k` times as large.
fn in_points(mut d: Document, k: f64) -> Document {
@@ -1992,6 +2657,61 @@ fn threaded_area_type_flows_through_its_frames() {
assert_eq!(r.document.text_threads, [ids]);
}
/// Replacing a group containing either threaded frame must use the PDF page instead.
#[test]
fn unread_group_holding_threaded_frame_falls_back() {
let frames = [
format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 >> >> >>", canvas_rect(50.0, 10.0, 54.0, 14.0)),
format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 >> >> >>", canvas_rect(70.0, 10.0, 74.0, 14.0)),
];
let text = document_of(&frames, &[story("Threaded\\r", &[0, 1], "/0 0", "/1 10.0")]);
for removed in [0, 1] {
let grouped = os1_group(&format!("{}\n{}\nZZ", square(if removed == 0 { 50 } else { 70 }, 10), frame_object(0, removed)));
let other = frame_object(0, 1 - removed);
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Mixed", true, &format!("{grouped}\n{other}"))), &text);
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_three_squares(), vec![], false, true, 1.0);
assert!(!read, "removed frame {removed}: {notes:?}");
assert_eq!(names(&doc), ["Layer 1"]);
assert!(notes.iter().any(|n| n.contains("an unread object holds a frame of threaded type")), "removed frame {removed}: {notes:?}");
}
}
/// Replacing an unread group may still discard a single-frame text object.
#[test]
fn unread_group_holding_single_frame_uses_page_art() {
let frame = format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 >> >> >>", canvas_rect(50.0, 10.0, 54.0, 14.0));
let text = document_of(&[frame], &[story("Single\\r", &[0], "/0 0", "/1 10.0")]);
let grouped = os1_group(&format!("{}\n{}\nZZ", square(50, 10), frame_object(0, 0)));
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Mixed", true, &grouped)), &text);
let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_two_squares(), vec![], false, false, 1.0);
assert!(read, "{notes:?}");
assert_eq!(names(&doc), ["Back", "Mixed"]);
assert!(doc.text_threads.is_empty());
}
/// The page's type inside an unread object's box comes in once, with the page's art that replaces
/// the object, whether every text object's type is made from the text document or not.
#[test]
fn page_type_inside_an_unread_object_comes_in_with_its_page_art() {
let unread = os1_group("0 0 1 0 k\n20 10 m\n50 10 L\n50 30 L\n20 30 L\nf\nZZ");
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show \
1 0 0 0 setcmykcolor 20 10 moveto 50 10 lineto 50 30 lineto 20 30 lineto closepath fill \
0 0 0 1 setcmykcolor /Helvetica findfont 8 scalefont setfont 22 14 moveto (Ex) show";
let made = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
let unmade =
last_frame_unread(&text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Note\r", 0, (8200.0, 8120.0), (0.0, 0.0), 0.0)]));
for (doc, objects) in [(made, text_object(0)), (unmade, text_object(0) + &text_object(1))] {
let art = native_with_text(&(layer("Words", true, &objects) + &layer("Mixed", true, &unread)), &doc);
let r = import(&eps(page, &art)).unwrap();
assert_eq!(names(&r.document), ["Words", "Mixed"], "{:?}", r.warnings);
let mut texts = all_texts(&r.document);
texts.retain(|t| !t.is_empty());
assert_eq!(texts, ["Hi there", "Ex"], "{:?}", r.warnings);
assert!(!r.warnings.iter().any(|w| w.starts_with("some of the page's type")), "{:?}", r.warnings);
assert!(r.warnings.iter().any(|w| w.contains("`ZZ` (1)")), "{:?}", r.warnings);
}
}
#[test]
fn shown_type_the_page_draws_in_pieces_comes_whole_from_the_text_document() {
// Type on a path the page draws letter by letter: the letters lie on the path, and only the
+50
View File
@@ -1492,6 +1492,56 @@ fn c_and_f_during_an_arc_gesture_go_to_the_arc_tool() {
assert_ne!(serde_json::from_str::<Value>(&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::<Value>(&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::<Value>(&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::<Value>(&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::<Value>(&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]
+3 -2
View File
@@ -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,
}
}
+49 -1
View File
@@ -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<Vec<(f32, krilla::color::Color, f32)>> {
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);
+40 -12
View File
@@ -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()
+1
View File
@@ -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
+7 -1
View File
@@ -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):
/// <https://www.unicode.org/reports/tr50/#vo>.
///
/// 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.
/// <https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html>
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
+1
View File
@@ -36,6 +36,7 @@ fn synthetic_glyphs(specs: &[(usize, usize, f64, char)]) -> Vec<SGlyph> {
vpal: None,
level: unicode_bidi::Level::ltr(),
no_break: false,
vertical_roman: false,
cursive: false,
})
.collect()
+6
View File
@@ -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<FontFace>, 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<usize>, seg: &Segment, feats: &OtFe
inline: None,
word_end: false,
no_break: gst.no_break,
vertical_roman: gst.standard_vertical_roman,
cursive,
});
}
+54
View File
@@ -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::<Vec<_>>(), plain_row.glyphs.iter().map(|g| g.advance).collect::<Vec<_>>());
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() {
+1 -1
View File
@@ -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,
+113 -16
View File
@@ -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<Overlay> {
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;
+100
View File
@@ -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();
+84 -3
View File
@@ -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<Point>,
@@ -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<Overlay> {
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<Action>| 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));
+41 -7
View File
@@ -114,8 +114,11 @@ pub struct GestureTool {
pub edit_within: f64,
/// "Close paths when ends are within N pixels".
pub close_within: f64,
/// Smart Guides for the Pencil's ends.
/// Smart Guides for the Pencil's ends and the press of a straight Knife cut.
snap: DrawSnap,
/// Where the pointer went down, before any snap: a straight Knife cut released there cuts
/// nothing, even when its press snapped elsewhere.
pressed: Point,
/// Where the Blob Brush's or the Eraser's tip outline shows: the pointer over the canvas,
/// unless Cmd/Ctrl lends a selection tool or Alt the Smooth tool.
tip_at: Option<Point>,
@@ -194,6 +197,7 @@ impl GestureTool {
edit_within: 12.0,
close_within: 15.0,
snap: DrawSnap::default(),
pressed: Point::ZERO,
tip_at: None,
}
}
@@ -203,6 +207,31 @@ impl GestureTool {
self.id == "pencil"
}
/// Does a straight Knife cut snap to anchors and paths? Only with Smart Guides on, which Snap to
/// Pixel and Snap to Grid turn off (`PointSnap::new`). Its ends snap as the Pencil's straight
/// line's do: the press, and the release unless Shift keeps it on a 45° step.
fn knife_snaps(&self, cx: &ToolContext) -> bool {
self.id == "knife" && self.straight && cx.smart_guides && !cx.snap_to_pixel && !cx.snap_to_grid
}
/// Does the end of a straight line snap: the Pencil's Alt line's, or a straight Knife cut's
/// without Shift?
fn line_end_snaps(&self, cx: &ToolContext) -> bool {
self.straight && self.unconstrained && (self.snaps() || self.knife_snaps(cx))
}
/// Where a stroke or straight line released at `p` ends: where it snaps when its end snaps.
fn release_end(&mut self, cx: &ToolContext, p: Point) -> Point {
if (self.snaps() && !self.smoothing && !self.straight) || self.line_end_snaps(cx) { self.snap.drag(cx, p, None) } else { p }
}
/// Is a straight line with its end at `end` back at its press point (within a pixel), so that
/// there is no line? A Knife cut also is when the pointer, at `pointer`, is back where it went
/// down, though its press snapped elsewhere.
fn back_at_press(&self, cx: &ToolContext, pointer: Point, end: Point) -> bool {
self.points.first().is_some_and(|s| s.distance(end) < cx.tol(1.0)) || (self.id == "knife" && self.pressed.distance(pointer) < cx.tol(1.0))
}
fn draws_path(&self) -> bool {
matches!(self.id, "pencil" | "paintbrush")
}
@@ -532,10 +561,14 @@ impl Tool for GestureTool {
end
} else if self.snaps() && !self.smoothing && self.extend.is_none() && self.redraw.is_none() {
self.snap.press(cx, p, &[], None)
} else if self.knife_snaps(cx) {
// A straight Knife cut, its overlay and Shift's 45° steps start from the snapped point.
self.snap.press_on_paths(cx, p)
} else {
p
};
self.clear();
self.pressed = p;
self.push(start, ev.pressure);
vec![]
}
@@ -554,19 +587,20 @@ impl Tool for GestureTool {
// Where the stroke would end: shown, taken on release.
end = self.snap.drag(cx, p, None);
}
let p = if self.straight && self.unconstrained && self.snaps() {
// Alt's line ends where a stroke would: shown, taken on release.
let p = if self.line_end_snaps(cx) {
// Alt's line ends where a stroke would, and a Knife cut's on the anchors and
// paths its press snaps to: shown, taken on release.
self.snap.drag(cx, p, None)
} else {
p
};
if self.straight {
if !self.unconstrained {
if !self.line_end_snaps(cx) {
// The press's Smart Guides would stay on screen: the line's end doesn't snap.
self.snap.clear();
}
// The line runs from the press point to the pointer, wherever the drag went.
if self.points.first().is_some_and(|s| s.distance(p) < cx.tol(1.0)) {
if self.back_at_press(cx, ev.pos, p) {
// Back at the press point there is no line: the one shown goes.
self.points.truncate(1);
return if std::mem::take(&mut self.began) { vec![Action::Cancel] } else { vec![] };
@@ -618,12 +652,12 @@ impl Tool for GestureTool {
// Alt only on release cannot turn a freehand Knife drag into a straight cut.
self.knife_shift(ev.mods);
let p = self.constrained(cx, p, ev.mods);
let end = if self.snaps() && !self.smoothing && (!self.straight || self.unconstrained) { self.snap.drag(cx, p, None) } else { p };
let end = self.release_end(cx, p);
self.snap.clear();
let mut cancel = false;
if self.straight {
self.points.truncate(1);
if self.points.first().is_some_and(|s| s.distance(end) >= cx.tol(1.0)) {
if !self.back_at_press(cx, ev.pos, end) {
self.push(end, ev.pressure);
} else {
cancel = self.began;
+97
View File
@@ -1351,6 +1351,103 @@ fn knife_shift_alt_cuts_at_45_degree_steps() {
assert_eq!(knife_points(acts.last().unwrap()), vec![(0.0, 0.0), (30.0, -10.0), (50.0, -60.0)]);
}
/// With Smart Guides on, a straight Knife cut starts at the anchor or path its press snaps to,
/// and its release snaps the same way, as the Pencil's straight line's ends do. Centers and lines
/// up do not move the press. The overlay shows the same line.
#[test]
fn knife_alt_cut_snaps_where_smart_guides_snap() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let alt = Mods { alt: true, ..Default::default() };
let mut t = GestureTool::new("knife");
// Near the rectangle's corner anchor: the cut starts on it, and the release near the opposite
// corner ends on that one.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 101.0, 99.0).with_mods(alt));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 201.0, 198.0).with_mods(alt));
assert_eq!(overlay_points(&t, &cx), vec![(100.0, 100.0), (200.0, 200.0)]);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 201.0, 198.0).with_mods(alt));
assert_eq!(knife_points(&a), vec![(100.0, 100.0), (200.0, 200.0)]);
assert!(t.overlays(&cx).is_empty());
// Near the top side, away from its anchors: the cut starts on the path.
let acts = drag_held(&mut t, &cx, &[(130.0, 101.5), (260.0, 240.0)], alt);
assert_eq!(knife_points(acts.last().unwrap()), vec![(130.0, 100.0), (260.0, 240.0)]);
// Near the rectangle's center, or in line with its top side, the press stays where it is.
for press in [(151.0, 149.0), (50.0, 101.5)] {
let acts = drag_held(&mut t, &cx, &[press, (260.0, 240.0)], alt);
assert_eq!(knife_points(acts.last().unwrap()), vec![press, (260.0, 240.0)]);
}
// An Alt-click on the snapped press cuts nothing, and neither does a drag back to where the
// pointer went down.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 101.0, 99.0).with_mods(alt));
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 101.0, 99.0).with_mods(alt)), vec![]);
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 101.0, 99.0).with_mods(alt));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 201.0, 198.0).with_mods(alt));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 101.2, 99.0).with_mods(alt));
assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { dashed: true, .. })), "the line goes");
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 101.2, 99.0).with_mods(alt)), vec![]);
// A freehand cut keeps its press.
let acts = drag(&mut t, &cx, &[(101.0, 99.0), (150.0, 120.0), (201.0, 198.0)], Mods::default());
assert_eq!(knife_points(acts.last().unwrap()), vec![(101.0, 99.0), (150.0, 120.0), (201.0, 198.0)]);
}
/// With Smart Guides off, a straight Knife cut starts where it is pressed, with Snap to Point on
/// or off.
#[test]
fn knife_alt_cut_without_smart_guides_keeps_its_press() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
let alt = Mods { alt: true, ..Default::default() };
let mut t = GestureTool::new("knife");
for snap_to_point in [false, true] {
cx.snap_to_point = snap_to_point;
let acts = drag_held(&mut t, &cx, &[(101.0, 99.0), (201.0, 198.0)], alt);
assert_eq!(knife_points(acts.last().unwrap()), vec![(101.0, 99.0), (201.0, 198.0)], "Snap to Point {snap_to_point}");
}
}
/// With Smart Guides on, a straight Knife cut does not start on art in a hidden layer, whose own
/// visibility is still on.
#[test]
fn knife_alt_cut_does_not_snap_to_hidden_art() {
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
d.node_mut(l).unwrap().visible = false;
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let alt = Mods { alt: true, ..Default::default() };
let mut t = GestureTool::new("knife");
let acts = drag_held(&mut t, &cx, &[(101.0, 99.0), (201.0, 198.0)], alt);
assert_eq!(knife_points(acts.last().unwrap()), vec![(101.0, 99.0), (201.0, 198.0)]);
}
/// With Smart Guides on, Shift and Alt keep the Knife's cut at 45° steps from the snapped press,
/// and an anchor near the release does not pull the cut off that step.
#[test]
fn knife_shift_alt_cut_steps_from_the_snapped_press() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let shift_alt = Mods { shift: true, alt: true, ..Default::default() };
let mut t = GestureTool::new("knife");
// Pressed by the bottom-left corner, released by the top-right one.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 101.0, 199.0).with_mods(shift_alt));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 203.0, 104.0).with_mods(shift_alt));
let shown = overlay_points(&t, &cx);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 203.0, 104.0).with_mods(shift_alt));
let cut = knife_points(&a);
assert_eq!(shown, cut);
assert_eq!(cut[0], (100.0, 200.0));
let step = 103f64.hypot(96.0) / 2f64.sqrt();
assert!((cut[1].0 - (100.0 + step)).abs() < 1e-9 && (cut[1].1 - (200.0 - step)).abs() < 1e-9, "{cut:?}");
}
/// The Eraser's Angle and Roundness (Eraser Tool Options) go with its stroke, kept within
/// −180–180° and 1–100%; while it drags, a tip that is not round shows the outline of the area
/// it covers, and a round one the drag as wide as the tip.
+8
View File
@@ -348,6 +348,14 @@ impl DrawSnap {
self.snap(cx, p, from)
}
/// Snap the point pressed at `p` onto the art's anchors and paths alone, within the Smart
/// Guides snapping tolerance (the Knife's straight cut). The targets serve this press only.
pub fn press_on_paths(&mut self, cx: &ToolContext, p: Point) -> Point {
let targets = Targets::for_drawing(cx.doc, &[], visible(cx), None).styled(cx).anchors_and_paths();
self.targets = Some((None, PointSnap { targets: Some((targets, cx.snapping_tolerance)) }));
self.snap(cx, p, None)
}
/// Gather `targets` other than the art for the drag a press starts (a point being dragged
/// leaves itself out); the next hover gathers its own again.
pub fn hold(&mut self, cx: &ToolContext, targets: impl FnOnce() -> Targets) {
+36 -19
View File
@@ -81,11 +81,11 @@ fn within<T>(sorted: &[T], key: impl Fn(&T) -> f64, v: f64, tol: f64) -> &[T] {
sorted.get(lo..hi).unwrap_or_default()
}
/// Visit `nodes` and everything inside them, but the nodes in `skip` and everything inside those:
/// one pass, however many are skipped. A Shaper Group's original shapes, its first child, are
/// not shown and are skipped too.
/// Visit `nodes` and everything inside them, but the nodes in `skip`, the hidden nodes and
/// everything inside those: one pass, however many are skipped. A Shaper Group's original
/// shapes, its first child, are not shown and are skipped too.
fn walk_but<'a>(nodes: &'a [Arc<Node>], skip: &HashSet<NodeId>, f: &mut impl FnMut(&'a Node)) {
for n in nodes.iter().filter(|n| !skip.contains(&n.id)) {
for n in nodes.iter().filter(|n| n.visible && !skip.contains(&n.id)) {
f(n);
if let Some(ch) = n.children() {
walk_but(ch.get(usize::from(n.extras().shaper.is_some())..).unwrap_or_default(), skip, f);
@@ -500,6 +500,19 @@ impl Targets {
self
}
/// Only these targets' anchors and path segments pull, and where segments cross: no centers,
/// bounds, lines up, extensions or glyph guides.
pub(super) fn anchors_and_paths(mut self) -> Self {
self.points.retain(|(_, k)| *k == Kind::Anchor);
self.xs.clear();
self.ys.clear();
self.rulers.clear();
self.boxes.clear();
self.extensions.clear();
self.angled.clear();
self
}
/// View → Snap to Point: only these targets' anchors pull, and the ruler guides (shown and
/// unlocked, as the selection tools pick them) pull the pointer into line where they run.
pub(super) fn for_snap_to_point(self, cx: &ToolContext) -> Self {
@@ -550,23 +563,27 @@ impl Targets {
}
/// Anchors and centres of the visible leaves under `roots` (the roots included) whose bounds
/// reach into `near`.
/// reach into `near`. Nothing inside a hidden group or layer counts.
pub(super) fn points_near(doc: &Document, roots: &[NodeId], near: Rect) -> Self {
let mut t = Targets::default();
for n in roots.iter().filter_map(|id| doc.node(*id)) {
crate::draw2::walk_shown(n, &mut |c| {
if c.is_container() || !c.visible {
return;
}
let Some(b) = c.geometric_bounds() else { return };
if b.x0 > near.x1 || b.x1 < near.x0 || b.y0 > near.y1 || b.y1 < near.y0 {
return;
}
if let NodeKind::Path { path, .. } = &c.kind {
t.points.extend(path.anchors().map(|(_, _, a)| (a.p, Kind::Anchor)));
}
t.points.push((b.center(), Kind::Center));
});
let mut push = |c: &Node| {
if c.is_container() {
return;
}
let Some(b) = c.geometric_bounds() else { return };
if b.x0 > near.x1 || b.x1 < near.x0 || b.y0 > near.y1 || b.y1 < near.y0 {
return;
}
if let NodeKind::Path { path, .. } = &c.kind {
t.points.extend(path.anchors().map(|(_, _, a)| (a.p, Kind::Anchor)));
}
t.points.push((b.center(), Kind::Center));
};
for n in roots.iter().filter_map(|id| doc.node(*id)).filter(|n| n.visible) {
push(n);
if let Some(ch) = n.children() {
walk_but(ch.get(usize::from(n.extras().shaper.is_some())..).unwrap_or_default(), &HashSet::new(), &mut push);
}
}
t.index()
}
+33
View File
@@ -260,6 +260,39 @@ fn shaper_sources_are_no_targets() {
assert!(gives_corner(&Targets::points_near(&d, &[b], near)));
}
/// Art inside a hidden group or layer is no target, even while its own visibility is on: not
/// for drawing, the Knife's straight cut, a move or a transform tool's reference point on the
/// shown group around it.
#[test]
fn art_in_hidden_groups_and_layers_is_no_target() {
use std::sync::Arc;
use vectorcraft_doc::{Appearance, Node};
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let square = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 400.0, 400.0)), Appearance::default_art());
let mut inner = Node::group(d.alloc_id(), vec![Arc::new(square)]);
inner.visible = false;
let outer = Node::group(d.alloc_id(), vec![Arc::new(inner)]);
let g = outer.id;
d.insert(Some(l), 1, outer).unwrap();
let corner = Point::new(300.0, 300.0);
let gives = |t: &Targets, at: Point| {
t.points.iter().any(|(p, _)| *p == at)
|| t.xs.iter().chain(&t.ys).any(|(_, p, _)| *p == at)
|| t.segments.iter().any(|(_, s)| s.start() == at || s.end() == at)
};
assert!(!gives(&Targets::collect(&d, &[], None), corner));
assert!(!gives(&Targets::for_drawing(&d, &[], None, None), corner));
assert!(!gives(&Targets::for_drawing(&d, &[], None, None).anchors_and_paths(), corner));
assert!(!gives(&Targets::points_near(&d, &[g], Rect::new(296.0, 296.0, 304.0, 304.0)), corner));
// The shown rectangle on the same layer still is one, until the layer is hidden.
let shown = Point::new(100.0, 100.0);
assert!(gives(&Targets::for_drawing(&d, &[], None, None).anchors_and_paths(), shown));
d.node_mut(l).unwrap().visible = false;
assert!(!gives(&Targets::collect(&d, &[], None), shown));
assert!(!gives(&Targets::for_drawing(&d, &[], None, None).anchors_and_paths(), shown));
}
/// #2120: the dragged end of a straight segment at any angle lands on the segment's own
/// extension (Line Extension), past its other end or short of it, the guide drawn from that
/// end; with two anchors moving there is none to keep to.
+2
View File
@@ -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"]),
+218 -6
View File
@@ -5,9 +5,10 @@
//! onto the clicked object (`reverse`); Shift+Alt-click: add the clicked object's appearance to the
//! selection's (`append`). Shift-click: sample only the colour under the cursor into the active
//! fill/stroke (`paint.sampleColor`; in a CMYK document an RGB color is stored as CMYK): a
//! gradient's color at that point, a pattern's tile art there (after the pattern's transform), or
//! a gradient mesh's interpolated patch color. Clicking an image samples its pixels (averaged over
//! the options' raster sample size; a CMYK image gives its ink amounts).
//! gradient's color at that point, a pattern's tile art there (after the pattern's transform), a
//! gradient mesh's interpolated patch color, or the art a blend or an envelope draws there (a
//! blend's steps, an envelope's distorted contents). Clicking an image samples its pixels
//! (averaged over the options' raster sample size; a CMYK image gives its ink amounts).
//!
//! The Gradient panel's eyedropper sets the `stop` option (the tool to return to): the next click
//! samples the colour under the cursor into the selected gradient stop (`paint.sampleColor
@@ -119,8 +120,8 @@ pub(crate) fn sample_source(cx: &ToolContext, p: Point) -> Option<(vectorcraft_d
Some((paint_owner(cx.doc, h.leaf), h.kind))
}
/// The visible color under `p`, including image pixels, pattern tiles, mesh colors and
/// intermediate gradient colors.
/// The visible color under `p`, including image pixels, pattern tiles, mesh colors, intermediate
/// gradient colors, the steps a blend draws and an envelope's distorted contents.
pub(crate) fn sample_color_at(cx: &ToolContext, p: Point) -> Option<Color> {
let (doc, opts, raster_sample) = (cx.doc, sample_options(cx), cx.raster_sample);
vectorcraft_doc::depth::with_deep_stack(|| {
@@ -134,7 +135,9 @@ pub(crate) fn sample_color_at(cx: &ToolContext, p: Point) -> Option<Color> {
/// The color the front-most object of `doc` hit at `p` with `opts` shows there. Where a stroke
/// gives no color at `p` (a gap between its pattern's tiles), the object's own fill there does;
/// an object that gives none at all is passed over for the ones below it. `levels` holds the
/// an object that gives none at all is passed over for the ones below it. A hit on a blend or an
/// envelope, or on an object it holds, samples the art it draws ([`live_color_at`]), and where
/// that art leaves `p` empty the whole blend or envelope is passed over. `levels` holds the
/// scratch documents for art inside art, one for each level deeper.
fn color_under(s: &mut Sampler, doc: &Document, levels: &mut [Option<Document>], p: Point, opts: HitOptions) -> Option<Color> {
let mut passed: HashSet<NodeId> = HashSet::new();
@@ -150,6 +153,15 @@ fn color_under(s: &mut Sampler, doc: &Document, levels: &mut [Option<Document>],
};
s.spend(cost)?;
let h = hit_test_visible_skipping(doc, p, opts, &|id| passed.contains(&id))?;
if let Some(live) = drawn_live(doc, &h.ancestry) {
if let Some(c) = live_color_at(s, levels, live, p) {
return Some(c);
}
if !passed.insert(live.id) {
return None;
}
continue;
}
let id = paint_owner(doc, h.leaf);
let n = doc.node(id)?;
let on_stroke = h.kind == HitKind::Stroke;
@@ -177,6 +189,23 @@ fn fill_covers(n: &Node, p: Point) -> bool {
}
}
/// The outermost blend or envelope (other than one whose contents are being edited) in `ancestry`,
/// a hit's path through `doc`: the canvas draws its evaluated art, not the objects it holds.
fn drawn_live<'d>(doc: &'d Document, ancestry: &[NodeId]) -> Option<&'d Node> {
ancestry.iter().filter_map(|id| doc.node(*id)).find(|n| matches!(n.kind, NodeKind::Blend { .. } | NodeKind::Envelope { editing: false, .. }))
}
/// The color the art blend or envelope `n` draws shows at `p`, sampled as [`art_color_at`] samples
/// art: a blend's keys and steps, an envelope's distorted contents, symbol instances in them by
/// their symbols' art. Evaluating it counts the objects it holds, and the art made counts its
/// objects.
fn live_color_at(s: &mut Sampler, levels: &mut [Option<Document>], n: &Node, p: Point) -> Option<Color> {
s.spend(n.count())?;
let art = vectorcraft_effects::expand_live_deep(Some(s.doc), n);
s.spend(art.count())?;
art_color_at(s, levels, [art], p)
}
/// The color `n` shows at `p`: an image's pixels, a mesh's interpolated patch color, a symbol
/// instance's art, or the color of its fill (or its stroke, when the stroke was hit or there is no
/// fill) there. Images, symbols and patterns are looked up in the sampled document.
@@ -1198,4 +1227,187 @@ mod tests {
assert_eq!(paint_color_at(&paint, None, Point::new(0.0, -50.0)).unwrap().to_hex(), "#000000");
assert_eq!(paint_color_at(&paint, None, Point::new(50.0, 0.0)).unwrap().to_hex(), "#808080");
}
/// An unstroked rectangle of `d` filled with `color`.
fn solid_rect(d: &mut vectorcraft_doc::Document, r: vectorcraft_geom::Rect, color: Color) -> Node {
let a = vectorcraft_doc::Appearance::basic(Paint::solid(color), Paint::None, 0.0);
Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(r), a)
}
/// [`doc_with_rect`] plus a green rectangle (290, 290)–(430, 350) and, over it, `live` made by
/// `make` from the document.
fn doc_with_live_over_green(make: impl FnOnce(&mut vectorcraft_doc::Document) -> Node) -> vectorcraft_doc::Document {
use vectorcraft_geom::Rect;
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let green = solid_rect(&mut d, Rect::new(290.0, 290.0, 430.0, 350.0), Color::rgb(0.0, 1.0, 0.0));
d.insert(Some(l), usize::MAX, green).unwrap();
let live = make(&mut d);
d.insert(Some(l), usize::MAX, live).unwrap();
d
}
/// Shift-click on a blend samples the step drawn under the cursor, and a gap between its steps
/// samples what lies below.
#[test]
fn shift_click_samples_the_blend_step_under_the_cursor() {
use vectorcraft_doc::live::{BlendSpacing, BlendSpec};
use vectorcraft_geom::Rect;
// Red (300, 300)–(320, 320) and blue (400, 300)–(420, 320) with 1 step, half red and half
// blue, at x 350 to 370.
let d = doc_with_live_over_green(|d| {
let red = solid_rect(d, Rect::new(300.0, 300.0, 320.0, 320.0), Color::rgb(1.0, 0.0, 0.0));
let blue = solid_rect(d, Rect::new(400.0, 300.0, 420.0, 320.0), Color::rgb(0.0, 0.0, 1.0));
let spec = BlendSpec { spacing: BlendSpacing::Steps(1), ..Default::default() };
Node::new(d.alloc_id(), NodeKind::Blend { children: vec![std::sync::Arc::new(red), std::sync::Arc::new(blue)], spec: Box::new(spec) })
});
let (s, p) = (Selection::default(), paint());
let shift = Mods { shift: true, ..Default::default() };
let at = |x: f64| EyedropperTool::default().pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, x, 310.0).with_mods(shift));
let mid = Color::rgb(0.5, 0.0, 0.5).to_hex();
assert_eq!(sampled_hex(&at(360.0)), mid);
assert_eq!(crate::xform::eyedropper::sample_color_at(&cx(&d, &s, &p), Point::new(360.0, 310.0)).map(|c| c.to_hex()), Some(mid));
assert_eq!(sampled_hex(&at(310.0)), "#ff0000");
// Between the red key and the step (x 320 to 350), and below the step.
assert_eq!(sampled_hex(&at(335.0)), "#00ff00");
let below = EyedropperTool::default().pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 360.0, 325.0).with_mods(shift));
assert_eq!(sampled_hex(&below), "#00ff00");
}
/// A blend of two instances of one symbol draws its steps as instances too: Shift-click on a
/// step samples the symbol's art there, and where that art leaves the point empty, what lies
/// below.
#[test]
fn shift_click_samples_symbol_art_in_a_blends_steps() {
use vectorcraft_doc::live::{BlendSpacing, BlendSpec};
use vectorcraft_geom::{Affine, Rect};
// A red 20 pt square at the symbol's origin, placed at x 300 and 400, with 1 step at x 350.
let d = doc_with_live_over_green(|d| {
let art = solid_rect(d, Rect::new(0.0, 0.0, 20.0, 20.0), Color::rgb(1.0, 0.0, 0.0));
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Dot".into(), art: std::sync::Arc::new(art) });
let mut instance = |x: f64| {
let kind = NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((x, 300.0)) };
std::sync::Arc::new(Node::new(d.alloc_id(), kind))
};
let children = vec![instance(300.0), instance(400.0)];
let spec = BlendSpec { spacing: BlendSpacing::Steps(1), ..Default::default() };
Node::new(d.alloc_id(), NodeKind::Blend { children, spec: Box::new(spec) })
});
let (s, p) = (Selection::default(), paint());
let sample = |x: f64, y: f64| crate::xform::eyedropper::sample_color_at(&cx(&d, &s, &p), Point::new(x, y)).map(|c| c.to_hex());
assert_eq!(sample(360.0, 310.0), Some("#ff0000".into()));
assert_eq!(sample(335.0, 310.0), Some("#00ff00".into()));
assert_eq!(sample(360.0, 325.0), Some("#00ff00".into()));
}
/// Align to Path turns a blend's keys and steps to its spine, past the keys' bounds: a click
/// there hits the blend and samples what it draws.
#[test]
fn shift_click_samples_blend_art_turned_past_its_bounds() {
use vectorcraft_doc::live::{BlendOrientation, BlendSpacing, BlendSpec};
use vectorcraft_geom::Rect;
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let green = solid_rect(&mut d, Rect::new(20.0, 20.0, 280.0, 280.0), Color::rgb(0.0, 1.0, 0.0));
d.insert(Some(l), usize::MAX, green).unwrap();
// 100 × 10 keys centred on (100, 100) and (200, 200): Align to Path turns them 45°, and the
// red one reaches (70, 66), above the keys' bounds (y 95 to 205).
let red = solid_rect(&mut d, Rect::new(50.0, 95.0, 150.0, 105.0), Color::rgb(1.0, 0.0, 0.0));
let blue = solid_rect(&mut d, Rect::new(150.0, 195.0, 250.0, 205.0), Color::rgb(0.0, 0.0, 1.0));
let spec = BlendSpec { spacing: BlendSpacing::Steps(1), orientation: BlendOrientation::AlignToPath, ..Default::default() };
let id = d.alloc_id();
let blend = Node::new(id, NodeKind::Blend { children: vec![std::sync::Arc::new(red), std::sync::Arc::new(blue)], spec: Box::new(spec) });
d.insert(Some(l), usize::MAX, blend).unwrap();
let (s, p) = (Selection::default(), paint());
let at = Point::new(70.0, 66.0);
assert!(d.node(id).unwrap().geometric_bounds().is_some_and(|b| !b.contains(at)));
assert_eq!(hit_test(&d, at, cx(&d, &s, &p).hit_options()).map(|h| h.leaf), Some(id));
assert_eq!(crate::xform::eyedropper::sample_color_at(&cx(&d, &s, &p), at).map(|c| c.to_hex()), Some("#ff0000".into()));
// Where the turned key leaves the point empty, the click passes the blend by.
assert_eq!(crate::xform::eyedropper::sample_color_at(&cx(&d, &s, &p), Point::new(60.0, 90.0)).map(|c| c.to_hex()), Some("#00ff00".into()));
// A hidden red key does not hit there.
if let Some(NodeKind::Blend { children, .. }) = d.node_mut(id).map(|n| &mut n.kind) {
std::sync::Arc::make_mut(&mut children[0]).visible = false;
}
assert_eq!(hit_test(&d, at, cx(&d, &s, &p).hit_options()).map(|h| h.leaf), Some(d.layers[0].children().unwrap()[1].id));
// A red key clipped to its right half, centred on (125, 100): the line to the blue key's
// centre turns it about 53°, and its hidden left half reaches (89, 52).
let mut clip = solid_rect(&mut d, Rect::new(100.0, 95.0, 150.0, 105.0), Color::BLACK);
clip.appearance = Default::default();
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
let red = solid_rect(&mut d, Rect::new(50.0, 95.0, 150.0, 105.0), Color::rgb(1.0, 0.0, 0.0));
let key = Node::new(d.alloc_id(), NodeKind::Group { children: vec![std::sync::Arc::new(clip), std::sync::Arc::new(red)], clip: true });
if let Some(NodeKind::Blend { children, .. }) = d.node_mut(id).map(|n| &mut n.kind) {
children[0] = std::sync::Arc::new(key);
}
let hidden = Point::new(89.0, 52.0);
assert!(d.node(id).unwrap().geometric_bounds().is_some_and(|b| !b.contains(hidden)));
assert_eq!(hit_test(&d, hidden, cx(&d, &s, &p).hit_options()).map(|h| h.leaf), Some(d.layers[0].children().unwrap()[1].id));
assert_eq!(crate::xform::eyedropper::sample_color_at(&cx(&d, &s, &p), hidden).map(|c| c.to_hex()), Some("#00ff00".into()));
}
/// The stroke of an envelope's distorted contents paints past the envelope's bounds: a click
/// there hits the envelope and samples the stroke.
#[test]
fn shift_click_samples_an_envelopes_stroke_past_its_bounds() {
use vectorcraft_geom::{Affine, Rect};
let mut id = None;
let d = doc_with_live_over_green(|d| {
let a = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 10.0);
let red = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 420.0, 320.0)), a);
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true };
let content = vec![std::sync::Arc::new(red)];
let n = Node::new(
d.alloc_id(),
NodeKind::Envelope {
content,
kind: Box::new(kind),
fidelity: 50.0,
editing: false,
options: Default::default(),
frame: Box::new(Affine::IDENTITY),
},
);
id = Some(n.id);
n
});
let (s, p) = (Selection::default(), paint());
// The middle of the band's top edge stays at y 300, and its 10 pt stroke reaches y 295.
let at = Point::new(360.0, 296.0);
assert!(d.node(id.unwrap()).unwrap().geometric_bounds().is_some_and(|b| !b.inflate(3.0, 3.0).contains(at)));
assert_eq!(hit_test(&d, at, cx(&d, &s, &p).hit_options()).map(|h| h.leaf), id);
assert_eq!(crate::xform::eyedropper::sample_color_at(&cx(&d, &s, &p), at).map(|c| c.to_hex()), Some("#0000ff".into()));
}
/// Shift-click on an envelope samples its distorted contents where they are drawn, and a point
/// in its bounds that they leave empty samples what lies below.
#[test]
fn shift_click_samples_the_envelopes_distorted_contents() {
use vectorcraft_geom::{Affine, Rect};
let d = doc_with_live_over_green(|d| {
let red = solid_rect(d, Rect::new(300.0, 300.0, 420.0, 320.0), Color::rgb(1.0, 0.0, 0.0));
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true };
let content = vec![std::sync::Arc::new(red)];
let options = Default::default();
Node::new(
d.alloc_id(),
NodeKind::Envelope { content, kind: Box::new(kind), fidelity: 50.0, editing: false, options, frame: Box::new(Affine::IDENTITY) },
)
});
let (s, p) = (Selection::default(), paint());
let sample = |x: f64, y: f64| crate::xform::eyedropper::sample_color_at(&cx(&d, &s, &p), Point::new(x, y)).map(|c| c.to_hex());
let art = vectorcraft_effects::expand_live_deep(Some(&d), d.layers[0].children().unwrap().last().unwrap());
let b = art.geometric_bounds().unwrap();
// The arc bends the ends of the band down, below the 20 pt it was, and keeps its middle.
assert!(b.y1 > 330.0, "{b:?}");
assert_eq!(sample(360.0, 310.0), Some("#ff0000".into()));
assert_eq!(sample(310.0, 332.0), Some("#ff0000".into()));
// Only what the art paints counts: just past the bent end's edge, within the click
// tolerance of it, the green below shows.
assert_eq!(sample(305.0, 332.0), Some("#00ff00".into()));
assert_eq!(sample(360.0, 332.0), Some("#00ff00".into()));
assert_eq!(sample(b.x0 + 0.5, b.y0 + 1.0), Some("#00ff00".into()));
}
}
+28 -1
View File
@@ -1,7 +1,8 @@
//! Document Tools in the interface: its submenu (Object › Plug-ins › Document Tools), which runs
//! Save a Timestamped Copy, New Symbols from Layers and Convert Layers to Symbols (the same, with
//! each layer's art replaced by an instance). Save a Timestamped Copy of a document never saved
//! asks for the folder to put it in ([`save_copy`]).
//! asks for the folder to put it in ([`save_copy`]). Export All Open Documents as PDF or SVG asks
//! for the folder the copies go in ([`export_all`], #4920).
use serde_json::{Value, json};
@@ -10,6 +11,8 @@ use crate::menus::Item;
/// Save a Timestamped Copy.
pub const SAVE_COPY: &str = "addon.document.saveTimestampedCopy";
/// Export All Open Documents.
pub const EXPORT_ALL: &str = "addon.document.exportAllOpen";
/// New Symbols from Layers (Convert Layers to Symbols: with their art replaced by instances).
const SYMBOLS: &str = "addon.document.symbolsFromLayers";
@@ -17,6 +20,8 @@ const SYMBOLS: &str = "addon.document.symbolsFromLayers";
pub fn menu() -> Vec<Item> {
vec![
Item::Cmd("Save a Timestamped Copy", SAVE_COPY, json!({})),
Item::Cmd("Export All Open Documents as PDF", EXPORT_ALL, json!({ "format": "pdf" })),
Item::Cmd("Export All Open Documents as SVG", EXPORT_ALL, json!({ "format": "svg" })),
Item::Sep,
Item::Cmd("New Symbols from Layers", SYMBOLS, json!({})),
Item::Cmd("Convert Layers to Symbols", SYMBOLS, json!({ "replace": true })),
@@ -35,5 +40,27 @@ pub(crate) fn save_copy(app: &mut VectorcraftApp, p: &Value) -> Result<Value, St
Ok(r)
}
/// Export All Open Documents with params `p`: without a `folder`, the copies go in the folder
/// picked. The status bar says how many were written and names the first that failed.
pub(crate) fn export_all(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let mut p = if p.is_object() { p.clone() } else { json!({}) };
if p.get("folder").is_none() {
p["folder"] = json!(crate::picks::folder(app).ok_or("cancelled")?);
}
let r = app.session.execute(EXPORT_ALL, &p).map_err(|e| e.to_string())?;
let written = r["files"].as_array().map_or(0, Vec::len);
let failed = r["failed"].as_array().cloned().unwrap_or_default();
let status = match failed.first() {
Some(f) => {
let args = [("title", f["title"].as_str().unwrap_or_default()), ("error", f["error"].as_str().unwrap_or_default())];
let (one, other) = ("Exported {n} document; {title} wasn't written: {error}", "Exported {n} documents; {title} wasn't written: {error}");
crate::i18n::msg_n(written as u64, one, other, &args)
}
None => crate::i18n::msg_n(written as u64, "Exported {n} document", "Exported {n} documents", &[]),
};
app.status(status);
Ok(r)
}
#[cfg(test)]
mod tests;
@@ -82,3 +82,44 @@ fn new_symbols_from_layers_and_convert_layers_to_symbols() {
.all(|l| matches!(l.children().unwrap()[..], [ref one] if matches!(one.kind, vectorcraft_doc::NodeKind::SymbolInstance { .. })))
);
}
/// #4920: Export All Open Documents as PDF asks for a folder and writes one PDF per open document,
/// named after each, without replacing a file there, and leaves every document as it was.
#[test]
fn export_all_open_documents_writes_each_to_the_folder_picked() {
let dir = folder("export-all");
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
app.run("document.save", json!({"path": dir.join("card.vectorcraft")})).unwrap();
app.run("file.new", json!({"width": 200, "height": 100, "name": "card"})).unwrap();
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let untitled = app.session.doc().unwrap().doc.title.clone();
let out = dir.join("out");
std::fs::create_dir_all(&out).unwrap();
std::fs::write(out.join(format!("{untitled}.pdf")), b"keep").unwrap();
let picked = out.to_string_lossy().into_owned();
app.services.pick_folder = Some(Box::new(move || Some(picked.clone())));
let active = app.session.active_index();
click(&mut app, "Export All Open Documents as PDF");
let mut names = files(&out);
names.sort();
let mut want = vec!["card.pdf".to_string(), "card-2.pdf".into(), format!("{untitled}.pdf"), format!("{untitled}-2.pdf")];
want.sort();
assert_eq!(names, want, "one per document, the taken names numbered");
assert_eq!(std::fs::read(out.join(format!("{untitled}.pdf"))).unwrap(), b"keep", "no file is replaced");
assert!(std::fs::read(out.join("card.pdf")).unwrap().starts_with(b"%PDF"));
assert_eq!(app.session.active_index(), active, "the active document stays active");
assert!(
app.session.documents().iter().all(|d| d.path.as_ref().is_none_or(|p| p.ends_with("card.vectorcraft"))),
"no document takes a copy's path"
);
assert!(app.ui.status.starts_with("Exported 3 documents"), "{}", app.ui.status);
// Cancelled: nothing written.
app.services.pick_folder = Some(Box::new(|| None));
assert_eq!(app.run(super::EXPORT_ALL, json!({})).unwrap_err(), "cancelled");
assert_eq!(files(&out).len(), 4);
// The engine command refuses a missing folder and an unknown format.
assert!(app.session.execute(super::EXPORT_ALL, &json!({})).is_err());
assert!(app.session.execute(super::EXPORT_ALL, &json!({"folder": out, "format": "doc"})).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
+2 -1
View File
@@ -1,6 +1,6 @@
//! Path Precision Tools in the interface: its submenu (Object › Plug-ins › Path Precision Tools),
//! which selects its tools, opens its panels and runs Split / Add Points at Crossings, Divide into
//! Dashes ([`dashes`]) and Convert to Parametric Shape; the Anchor Inspector panel (the selected
//! Dashes ([`dashes`]), Expand Dashes into Paths and Convert to Parametric Shape; the Anchor Inspector panel (the selected
//! anchor's position and handles as numbers, the add-on's anchor edits, the Corner Tool's values,
//! [`corner`], and the Shared Edge Merge Tool's, [`shared_edge`]); the Parametric Shape panel
//! ([`shape`]); and its tools' options dialog (a double-click on the tool). Everything acts through `addon.pathtools.*` and
@@ -65,6 +65,7 @@ pub fn menu() -> Vec<Item> {
Item::Cmd("Add Points at Crossings", "addon.pathtools.crossings", json!({})),
Item::Cmd("Split at Crossings", "addon.pathtools.crossings", json!({ "cut": true })),
Item::Cmd("Divide into Dashes…", dashes::COMMAND, Value::Null),
Item::Cmd("Expand Dashes into Paths", "addon.pathtools.expandDashes", json!({})),
Item::Cmd("Convert to Parametric Shape", "addon.pathtools.shape.convert", json!({})),
]
}
@@ -33,6 +33,16 @@ fn the_plugins_submenu_selects_the_tools_and_opens_the_anchor_inspector() {
assert_eq!(e.command.as_deref(), Some("addon.pathtools.crossings"));
assert_eq!(e.params.get("cut").and_then(|v| v.as_bool()).unwrap_or(false), cut);
}
// Expand Dashes into Paths runs at once: a dashed line becomes its dashes.
let line = app.run("path.create", json!({"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}]})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [line]})).unwrap();
app.run("stroke.set", json!({"dash": [30, 10]})).unwrap();
let expand = under("Expand Dashes into Paths").expect("listed");
let (cmd, p) = menus::click_target(&expand.label, expand.command.as_deref().unwrap(), &expand.params);
menus::invoke(&mut app, &cmd, p);
let selected = app.session.doc().unwrap().selection.objects.len();
assert!(selected >= 3, "three dashes selected: {selected}");
app.run("edit.undo", json!({})).unwrap();
// Artwork Cleanup's submenu and Install Plug-in… are still there.
assert!(entries.iter().any(|e| e.path == ["Object", "Plug-ins", "Artwork Cleanup"]));
assert!(entries.iter().any(|e| e.path == ["Object", "Plug-ins"] && e.label == "Install Plug-in…"));
+2
View File
@@ -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,
@@ -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<Value, String> {
pub(crate) fn fields_from_attrs(attrs: &Map<String, Value>) -> Map<String, Value> {
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<Map<String, Value>, 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<Map<String, Value>, 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!({}));
@@ -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<Value, String> {
let o = app.session.tool_options_of(TOOL);
let fields: Map<String, Value> = 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<Value, String> {
let values: Map<String, Value> = 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 { .. })));
}
}
+14
View File
@@ -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 Динамичен символ
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -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 Δυναμικό σύμβολο
Can't render this file because it contains an unexpected character in line 164 and column 5.
+14
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2190 and column 5.
+14
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -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 ダイナミックシンボル
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -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 დინამიკური სიმბოლო
Can't render this file because it is too large.
+14
View File
@@ -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 동적 심볼
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+14
View File
@@ -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 Динамический символ
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+14
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+14
View File
@@ -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 Динамічний символ
Can't render this file because it contains an unexpected character in line 178 and column 5.
+14
View File
@@ -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
Can't render this file because it contains an unexpected character in line 200 and column 5.
+14
View File
@@ -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 动态符号
Can't render this file because it contains an unexpected character in line 2 and column 73.
+14
View File
@@ -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 動態符號
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -531,6 +531,7 @@ fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
crate::addons::pathtools::shared_edge::COMMAND => crate::addons::pathtools::shared_edge::open(app),
crate::addons::arrange::ZOOM => crate::addons::arrange::zoom(app),
crate::addons::document::SAVE_COPY => crate::addons::document::save_copy(app, p),
crate::addons::document::EXPORT_ALL => crate::addons::document::export_all(app, p),
"tool.setOption" => app.session.set_tool_option_cmd(p),
"effect.dialog" => crate::dialogs::open_effect_dialog(app, p),
"ui.recolorDialog" => crate::dialogs::recolor::open(app, p),
File diff suppressed because one or more lines are too long
+19 -1
View File
@@ -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<usize> = 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::<RowDrag>().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::<RowDrag>().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)));
@@ -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<egui::Event>| {
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::<Vec<_>>();
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.
+13 -5
View File
@@ -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
}),
);
+41 -4
View File
@@ -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())]);
+142 -6
View File
@@ -1,11 +1,12 @@
//! Glyphs panel: every character the current font maps (its cmap), drawn from the font's own
//! outlines. Double-click inserts the character at the Type tool's caret (or appends it to the
//! selected text objects).
//! selected text objects). The character selected with the Type tool is highlighted in the grid.
use std::collections::HashMap;
use egui::{Color32, Sense, Stroke, StrokeKind, Ui, vec2};
use serde_json::json;
use vectorcraft_doc::NodeKind;
use vectorcraft_geom::{Affine, BezPath, PathEl};
use vectorcraft_text::FontDb;
@@ -144,6 +145,28 @@ fn glyph_texture(ctx: &egui::Context, face: &vectorcraft_text::FontFace, gid: u3
tex
}
/// The character the grid highlights: the first one of the Type tool's selected range, else the
/// one before its caret. Public behaviour: the Glyphs panel shows the selected character's glyph
/// (the overview page cited at [`CELL_STEPS`]). Walks the runs, not the characters, so it stays
/// cheap on every frame.
fn selected_char(app: &VectorcraftApp) -> Option<char> {
let (id, a, b) = super::character::text_editing(app)?;
let NodeKind::Text(t) = &app.session.active()?.doc.node(id)?.kind else { return None };
let (at, range) = (a.min(b), a != b);
let mut start = 0;
for run in &t.runs {
let end = start + run.text.len();
if range && (start..end).contains(&at) {
return run.text.get(at - start..)?.chars().next();
}
if !range && at > start && at <= end {
return run.text.get(..at - start)?.chars().next_back();
}
start = end;
}
None
}
/// Insert `ch` at the Type tool's caret, else append it to the selected text.
fn insert(app: &mut VectorcraftApp, ctx: &egui::Context, ch: char) {
let s = ch.to_string();
@@ -193,17 +216,35 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let px = (cell * ui.ctx().pixels_per_point()).round().clamp(8.0, 256.0) as u32;
let list_h = (ui.available_height() - 64.0).max(cell * 3.0);
let hover_id = egui::Id::unique("gl-hover");
egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, false]).show_rows(ui, cell, rows, |ui, range| {
for r in range {
let selected = selected_char(app);
// The selected character and the rows shown last frame: the grid scrolls to the selected glyph
// only when the selection moves to another character, so it still scrolls freely meanwhile.
let follow_id = egui::Id::unique("gl-follow");
let (followed, shown): (Option<char>, std::ops::Range<usize>) = ui.ctx().data(|d| d.get_temp(follow_id)).unwrap_or_default();
let mut area = egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, false]);
if selected != followed
&& let Some(c) = selected
&& let Ok(i) = chars.binary_search_by_key(&c, |x| x.0)
&& !shown.contains(&(i / cols))
{
let row_h = cell + ui.spacing().item_spacing.y;
area = area.vertical_scroll_offset(((i / cols) as f32 + 0.5) * row_h - list_h * 0.5);
}
let shown = area.show_rows(ui, cell, rows, |ui, range| {
for r in range.clone() {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
for &(c, gid) in chars.iter().skip(r * cols).take(cols) {
let (rect, resp) = ui.allocate_exact_size(vec2(cell, cell), Sense::click());
let chosen = selected == Some(c);
if resp.hovered() {
ui.painter().rect_filled(rect, 2, t.hover);
ui.ctx().data_mut(|d| d.insert_temp(hover_id, (c, gid)));
} else if chosen {
ui.painter().rect_filled(rect, 2, t.row_selected);
}
ui.painter().rect_stroke(rect.shrink(0.5), 0, egui::Stroke::new(0.5, t.border), egui::StrokeKind::Inside);
let edge = if chosen { Stroke::new(1.5, t.accent) } else { Stroke::new(0.5, t.border) };
ui.painter().rect_stroke(rect.shrink(0.5), 0, edge, StrokeKind::Inside);
let tex = glyph_texture(ui.ctx(), &face, gid, px);
ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), t.text_strong);
let resp = resp.on_hover_text(format!("U+{:04X} {c} (glyph {gid})\n{}", c as u32, tl!("Double-click to insert")));
@@ -213,7 +254,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
});
}
range
});
ui.ctx().data_mut(|d| d.insert_temp(follow_id, (selected, shown.inner)));
ui.add_space(4.0);
let info = ui.ctx().data(|d| d.get_temp::<(char, u32)>(hover_id));
ui.label(
@@ -295,14 +338,46 @@ mod tests {
use super::*;
fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<egui::Event>) -> (Vec<(String, Pos2)>, Vec<f32>) {
fn shapes(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<egui::Event>) -> Vec<Shape> {
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 420.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
out.textures_delta.clear();
let shapes: Vec<Shape> = out.shapes.into_iter().map(|c| c.shape).collect();
out.shapes.into_iter().map(|c| c.shape).collect()
}
fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<egui::Event>) -> (Vec<(String, Pos2)>, Vec<f32>) {
let shapes = shapes(ctx, app, events);
(text_anchors(&shapes), square_sides(&shapes))
}
/// The Type tool editing `text` in a new document.
fn editing(text: &str) -> (VectorcraftApp, serde_json::Value) {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 200})).unwrap();
app.session.select_tool("type", Default::default()).unwrap();
let id = app.run("text.create", json!({"x": 20, "y": 50, "text": text})).unwrap()["id"].clone();
app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap();
(app, id)
}
fn select(app: &mut VectorcraftApp, start: usize, end: usize) {
app.session.set_tool_option("select", &json!({"start": start, "end": end}));
}
/// Centres of the highlighted (selected-character) cells.
fn highlighted(shapes: &[Shape], t: &Tokens) -> Vec<Pos2> {
fn walk(s: &Shape, fill: Color32, out: &mut Vec<Pos2>) {
match s {
Shape::Rect(r) if r.fill == fill => out.push(r.rect.center()),
Shape::Vec(v) => v.iter().for_each(|s| walk(s, fill, out)),
_ => {}
}
}
let mut out = vec![];
shapes.iter().for_each(|s| walk(s, t.row_selected, &mut out));
out
}
fn text_anchors(shapes: &[Shape]) -> Vec<(String, Pos2)> {
fn walk(s: &Shape, out: &mut Vec<(String, Pos2)>) {
match s {
@@ -350,6 +425,67 @@ mod tests {
frame(ctx, app, vec![button(false)]);
}
#[test]
fn the_selected_character_is_the_range_start_or_the_one_before_the_caret() {
let (mut app, id) = editing("aHé");
select(&mut app, 1, 2);
assert_eq!(selected_char(&app), Some('H'));
select(&mut app, 2, 2);
assert_eq!(selected_char(&app), Some('H'));
select(&mut app, 4, 1);
assert_eq!(selected_char(&app), Some('H'), "a backward range starts at its lower end");
select(&mut app, 0, 0);
assert_eq!(selected_char(&app), None, "nothing before the caret");
// Across runs: a style on `H` alone splits the text into three runs.
app.run("text.setRangeStyle", json!({"id": id, "start": 1, "end": 2, "kerning": 50})).unwrap();
select(&mut app, 1, 1);
assert_eq!(selected_char(&app), Some('a'));
select(&mut app, 2, 2);
assert_eq!(selected_char(&app), Some('H'));
select(&mut app, 2, 4);
assert_eq!(selected_char(&app), Some('é'));
app.session.select_tool("select", Default::default()).unwrap();
assert_eq!(selected_char(&app), None, "only the Type tool's selection counts");
}
#[test]
fn the_selected_glyph_is_highlighted_and_scrolled_into_view() {
let (mut app, _) = editing("A");
let ctx = egui::Context::default();
let t = Tokens::get(&ctx);
let (texts, _) = frame(&ctx, &mut app, vec![]);
if texts.iter().any(|(t, _)| t.contains("No fonts are available")) {
eprintln!("glyphs highlight: no fonts installed, grid not drawn");
return;
}
let (family, style) = super::super::character::text_style(&app).map(|(s, _)| (s.font_family, s.font_style)).unwrap();
let face = FontDb::global().face(&family, &style).unwrap();
let chars: Vec<char> = face.chars().into_iter().map(|(c, _)| c).filter(|c| !c.is_control() && !c.is_whitespace()).collect();
let (Some(&first), Some(&last)) = (chars.first(), chars.last()) else { return };
if chars.len() < 200 {
eprintln!("glyphs highlight: font too small to need scrolling");
return;
}
let (mut app, _) = editing(&format!("{first}{last}"));
let end = first.len_utf8() + last.len_utf8();
select(&mut app, end, end);
let ctx = egui::Context::default();
// The caret sits after the last glyph of the font, far below the first screen of the grid.
let lit = highlighted(&shapes(&ctx, &mut app, vec![]), &t);
assert_eq!(lit.len(), 1, "the grid scrolled to the selected glyph and highlighted it: {lit:?}");
let lit = highlighted(&shapes(&ctx, &mut app, vec![]), &t);
assert_eq!(lit.len(), 1, "it stays highlighted");
// Highlighting the first character scrolls back up to it.
select(&mut app, 0, 1);
shapes(&ctx, &mut app, vec![]);
let top = highlighted(&shapes(&ctx, &mut app, vec![]), &t);
assert_eq!(top.len(), 1, "{top:?}");
assert!(top[0].y < lit[0].y, "the first glyph sits at the top of the grid: {top:?} vs {lit:?}");
// The caret at the start selects nothing.
select(&mut app, 0, 0);
assert!(highlighted(&shapes(&ctx, &mut app, vec![]), &t).is_empty());
}
#[test]
fn zoom_stops_at_the_ends() {
assert_eq!(nudge_cell(CELL_DEFAULT, 1), CELL_DEFAULT + 1);
+4 -3
View File
@@ -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());

Some files were not shown because too many files have changed in this diff Show More