Merge main into fix/3179-envelope-type-note

# Conflicts:
#	docs/file-format-parity.md
This commit is contained in:
jmverges
2026-11-10 03:15:38 +00:00
20 changed files with 745 additions and 83 deletions
+325 -39
View File
@@ -1,10 +1,10 @@
//! Where a path's strokes paint, from the path's geometry without building their outlines: the
//! visual bounds and the clicks of a stroke take in its alignment, its width profile, miter spikes
//! up to the miter limit, projecting caps and arrowheads.
//! up to the miter limit, projecting caps and arrowheads. Butt caps end at the path's end points.
use vectorcraft_geom::bez::{CORNER_COS, has_length, kink, segments, subpaths, tangent, unit};
use vectorcraft_geom::hit::fill_contains;
use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest};
use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveExtrema, ParamCurveNearest};
use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathSeg, Point, Rect, Shape, Vec2};
use crate::appearance::HEAD_REACH;
@@ -101,6 +101,58 @@ fn profile_at(profile: &WidthProfile, segs: &[PathSeg], closed: bool, i: usize,
w
}
/// Does the curvature of `seg` stay below one over `side` all along it? A curve's first derivative
/// is a mix of its three control differences, and its second a mix of their two differences, which
/// bound the curvature from above. Then the edges of a stroke reaching `side` from it do not fold
/// back, and those edges reach farthest at the ends of `seg` or where its tangent is square to an
/// axis.
fn gentle(seg: &PathSeg, side: f64) -> bool {
let c = seg.to_cubic();
let d = [c.p1 - c.p0, c.p2 - c.p1, c.p3 - c.p2].map(|v| v * 3.0);
let along = unit(d[0] + d[1] + d[2]);
let slowest = d.iter().map(|v| v.dot(along)).fold(f64::INFINITY, f64::min);
let bend = 2.0 * (d[1] - d[0]).hypot().max((d[2] - d[1]).hypot());
slowest > 0.0 && side * bend < slowest * slowest
}
/// The box a stroke reaching `side` from `seg` paints when it stops square at both ends of `seg`:
/// the corners at the ends and the points across the stroke where the tangent is square to an axis.
/// A curve that bends tighter than `side` is halved up to `depth` times, and a piece still bending
/// that much is grown by `side` on every side.
fn swept_box(seg: &PathSeg, side: f64, depth: u32) -> Rect {
if !matches!(seg, PathSeg::Line(_)) && !gentle(seg, side) {
if depth == 0 {
return ParamCurveExtrema::bounding_box(seg).inflate(side, side);
}
return swept_box(&seg.subsegment(0.0..0.5), side, depth - 1).union(swept_box(&seg.subsegment(0.5..1.0), side, depth - 1));
}
let across = |t: f64| {
let (p, tn) = (seg.eval(t), tangent(seg, t));
let n = Vec2::new(-tn.y, tn.x) * side;
[p + n, p - n]
};
let [a, b] = across(0.0);
seg.extrema().into_iter().chain([1.0]).flat_map(across).fold(Rect::from_points(a, b), |r, p| r.union_pt(p))
}
/// The box of the arc a round join paints `side` from `at`, where the path turns from tangent
/// `t_in` to `t_out`: the arc runs on the outer side of the turn, and its box comes from its ends
/// and the points on it square to an axis. A joint where the path turns back on itself is given
/// the whole disc's box, and one where it does not turn paints no arc (`None`).
fn round_join_box(at: Point, t_in: Vec2, t_out: Vec2, side: f64) -> Option<Rect> {
let (t_in, t_out) = (unit(t_in), unit(t_out));
let turn = t_in.cross(t_out);
if turn.abs() < 1e-12 {
return (t_in.dot(t_out) < 0.0).then(|| Rect::from_center_size(at, (2.0 * side, 2.0 * side)));
}
let s = turn.signum();
let outer = |t: Vec2| Vec2::new(t.y, -t.x) * s;
let (a, b) = (outer(t_in), outer(t_out));
let axes = [Vec2::new(1.0, 0.0), Vec2::new(-1.0, 0.0), Vec2::new(0.0, 1.0), Vec2::new(0.0, -1.0)];
let arc = Rect::from_points(at + a * side, at + b * side);
Some(axes.into_iter().filter(|d| a.cross(*d) * s >= 0.0 && d.cross(b) * s >= 0.0).fold(arc, |r, d| r.union_pt(at + d * side)))
}
/// Is `p` inside the convex polygon `pts` (either winding), or within `tol` of it?
fn in_convex(pts: &[Point], p: Point, tol: f64) -> bool {
let n = pts.len();
@@ -115,39 +167,81 @@ fn in_convex(pts: &[Point], p: Point, tol: f64) -> bool {
impl StrokeLayer {
/// Does bounding this stroke need its path's shape: miter spikes, projecting caps, arrowheads,
/// or an alignment that depends on whether the path is closed? Else it reaches evenly round it.
/// an alignment that depends on whether the path is closed, or butt caps, which stop at the
/// ends of an open path? Else it reaches evenly round it.
fn needs_shape(&self) -> bool {
self.join == LineJoin::Miter
|| self.cap == LineCap::Square
|| self.cap != LineCap::Round
|| self.start_arrow.is_some()
|| self.end_arrow.is_some()
|| self.align != StrokeAlign::Center
}
/// The box this stroke paints along `bp`, whose geometric bounds are `bounds`: the bounds grown
/// by the body's reach, with the tips of the miter spikes the limit keeps, the corners of
/// The box this stroke paints along `segs`, the segments with length of an open subpath, when
/// its caps are butt caps: each segment's [`swept_box`] and, with round joins, the arc each
/// joint between segments paints ([`round_join_box`]), a shallow one included.
/// `None` when `segs` is empty. Butt dashes stop inside the same box. The heads and miter tips
/// are measured apart. A segment in the middle is measured only when its own box grown by
/// `side`, which holds everything it paints, is not inside the box so far.
fn butt_body(&self, segs: &[PathSeg], side: f64) -> Option<Rect> {
let (first, last) = (segs.first()?, segs.last()?);
let ends = swept_box(first, side, 3).union(swept_box(last, side, 3));
let body = segs.iter().skip(1).take(segs.len().saturating_sub(2)).fold(ends, |b, s| {
if b.contains_rect(ParamCurveExtrema::bounding_box(s).inflate(side, side)) { b } else { b.union(swept_box(s, side, 3)) }
});
if self.join != LineJoin::Round {
return Some(body);
}
Some(joints(segs, false).fold(body, |b, (at, t_in, t_out)| round_join_box(at, t_in, t_out, side).map_or(b, |j| b.union(j))))
}
/// The box this stroke paints along `bp` (geometric bounds `bounds`): an open subpath with butt
/// caps by [`Self::butt_body`], every other subpath by the box of its segments grown by its
/// reach, with `tips` and, when `measure_tips`, the miter spikes the limit keeps, the corners of
/// projecting caps and the reach of the arrowheads round their end points.
pub fn bounds_on(&self, bp: &BezPath, bounds: Rect) -> Rect {
fn reach_along(&self, bp: &BezPath, bounds: Rect, tips: Option<Rect>, measure_tips: bool) -> Rect {
let els = bp.elements();
let mut out = bounds;
let subs = subpaths(bp);
let mut out = tips.map_or(bounds, |t| bounds.union(t));
let mut side_max: f64 = 0.0;
// Every subpath reaches evenly unless one is open and butt-capped; only then is each
// subpath's own box needed.
let even_only = self.cap != LineCap::Butt || subs.iter().all(|s| s.1);
let mut even = even_only.then_some(bounds);
// Dashes put a cap on every dash: projecting ones reach a corner's diagonal anywhere.
let capped_dashes = self.cap == LineCap::Square && self.dash.as_ref().is_some_and(Dash::is_dashed);
for (r, closed) in subpaths(bp) {
for (r, closed) in subs {
let side = self.side_reach(closed);
side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side });
let butt = self.cap == LineCap::Butt && !closed;
if !butt {
side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side });
}
if side <= 0.0 {
continue;
}
let square = self.cap == LineCap::Square && !closed;
let miters = measure_tips && self.join == LineJoin::Miter;
if !(butt || square || miters || !even_only) {
continue;
}
let segs = drawn(&els[r]);
if self.join == LineJoin::Miter {
if miters {
for (at, t_in, t_out) in joints(&segs, closed) {
if let Some([_, tip, _]) = miter(at, t_in, t_out, side, self.miter_limit) {
out = out.union_pt(tip);
}
}
}
if self.cap == LineCap::Square && !closed {
if butt {
if let Some(body) = self.butt_body(&segs, side) {
out = out.union(body);
}
continue;
}
if !even_only {
even = vectorcraft_geom::union_opt(even, segs.iter().map(ParamCurveExtrema::bounding_box).reduce(|a, b| a.union(b)));
}
if square {
for (at, dir) in ends(&segs).into_iter().flatten() {
let n = Vec2::new(-dir.y, dir.x) * side;
out = out.union_pt(at + dir * side + n).union_pt(at + dir * side - n);
@@ -158,7 +252,15 @@ impl StrokeLayer {
let r = self.head_reach(end);
out = out.union(Rect::from_center_size(at, (2.0 * r, 2.0 * r)));
}
out.union(bounds.inflate(side_max, side_max))
even.map_or(out, |e| out.union(e.inflate(side_max, side_max)))
}
/// The box this stroke paints along `bp`, whose geometric bounds are `bounds`: the bounds grown
/// by the body's reach, with the tips of the miter spikes the limit keeps, the corners of
/// projecting caps and the reach of the arrowheads round their end points. An open subpath
/// with butt caps does not reach past its ends ([`Self::butt_body`]).
pub fn bounds_on(&self, bp: &BezPath, bounds: Rect) -> Rect {
self.reach_along(bp, bounds, None, true)
}
/// The tip of the miter spike this stroke paints where segment `b` follows segment `a` in a
@@ -176,30 +278,7 @@ impl StrokeLayer {
/// every joint of `bp` ([`Self::miter_tip`]) or `None` when there are none, instead of measured
/// along `bp`. It measures the projecting caps and arrowheads as [`Self::bounds_on`] does.
pub fn bounds_with_tips(&self, bp: &BezPath, bounds: Rect, tips: Option<Rect>) -> Rect {
let els = bp.elements();
let mut out = tips.map_or(bounds, |t| bounds.union(t));
let mut side_max: f64 = 0.0;
// Dashes put a cap on every dash: projecting ones reach a corner's diagonal anywhere.
let capped_dashes = self.cap == LineCap::Square && self.dash.as_ref().is_some_and(Dash::is_dashed);
for (r, closed) in subpaths(bp) {
let side = self.side_reach(closed);
side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side });
if side <= 0.0 {
continue;
}
if self.cap == LineCap::Square && !closed {
let segs = drawn(&els[r]);
for (at, dir) in ends(&segs).into_iter().flatten() {
let n = Vec2::new(-dir.y, dir.x) * side;
out = out.union_pt(at + dir * side + n).union_pt(at + dir * side - n);
}
}
}
for (at, _, end) in head_ends(self, bp) {
let r = self.head_reach(end);
out = out.union(Rect::from_center_size(at, (2.0 * r, 2.0 * r)));
}
out.union(bounds.inflate(side_max, side_max))
self.reach_along(bp, bounds, tips, false)
}
/// Does this stroke along `bp` (filled with `rule`) paint within `tol` of `p`? Inside and
@@ -314,7 +393,7 @@ impl Appearance {
}
/// [`Self::stroked_bounds`] with the box of each visible stroke that needs the path's shape
/// (miter spikes, projecting caps, arrowheads or an alignment) given by `shaped`.
/// (miter spikes, projecting or butt caps, arrowheads or an alignment) given by `shaped`.
pub fn stroked_bounds_by(&self, bounds: Rect, mut shaped: impl FnMut(&StrokeLayer) -> Rect) -> Rect {
self.painted_strokes().fold(bounds, |acc, s| {
if !s.needs_shape() {
@@ -325,6 +404,30 @@ impl Appearance {
})
}
/// Does this appearance have a visible butt-capped stroke?
pub fn has_butt_stroke(&self) -> bool {
self.painted_strokes().any(|s| s.cap == LineCap::Butt)
}
/// Does a visible butt-capped stroke of this appearance run along an open subpath of `bp` with
/// a segment that starts or ends at a slant, not square to the axes, once placed by `a`? The
/// visual box's margin at the stroke's corners there depends on their direction, which a scale
/// out of proportion changes.
pub fn has_slanted_butt_stroke(&self, bp: &BezPath, a: vectorcraft_geom::Affine) -> bool {
if !self.has_butt_stroke() {
return false;
}
let slanted = |v: Vec2| {
let d = unit(a * v.to_point() - a * Point::ZERO);
(d.x * d.y).abs() > 1e-9
};
let els = bp.elements();
subpaths(bp)
.into_iter()
.filter(|(_, closed)| !closed)
.any(|(r, _)| drawn(&els[r]).iter().any(|s| slanted(tangent(s, 0.0)) || slanted(tangent(s, 1.0))))
}
/// Does a visible stroke of this appearance along `bp` (filled with `rule`) paint within
/// `tol` of `p` ([`StrokeLayer::hits`])?
pub fn stroke_hit(&self, bp: &BezPath, rule: FillRule, p: Point, tol: f64) -> bool {
@@ -332,6 +435,31 @@ impl Appearance {
}
}
/// How many symbol instances [`crate::Node::has_slanted_butt_stroke`] looks into for one object
/// before it answers yes without looking further.
pub const SLANT_INSTANCES: usize = 4096;
impl crate::Node {
/// Does a visible butt-capped stroke of this object, of an object in it or of an object in a
/// symbol instance's art from `symbols`, placed by `a`, run along an open path with a segment
/// at a slant ([`Appearance::has_slanted_butt_stroke`])? Past [`SLANT_INSTANCES`] instances it
/// answers yes.
pub fn has_slanted_butt_stroke(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine) -> bool {
self.slanted_butt(symbols, a, &std::cell::Cell::new(SLANT_INSTANCES))
}
/// [`Self::has_slanted_butt_stroke`] with `left` instances still to look into.
fn slanted_butt(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine, left: &std::cell::Cell<usize>) -> bool {
if let crate::NodeKind::SymbolInstance { symbol, xf } = &self.kind {
let Some(l) = left.get().checked_sub(1) else { return true };
left.set(l);
return symbols.measure_art(symbol, |art| art.slanted_butt(symbols, a * *xf, left)).unwrap_or(false);
}
(self.appearance.has_butt_stroke() && self.stroke_path().is_some_and(|bp| self.appearance.has_slanted_butt_stroke(&bp, a)))
|| self.children().is_some_and(|c| c.iter().any(|c| c.slanted_butt(symbols, a, left)))
}
}
#[cfg(test)]
mod tests {
use vectorcraft_color::{Color, Paint};
@@ -378,7 +506,7 @@ mod tests {
let (small, big) = (at(100.0).bounds_on(&l, b), at(300.0).bounds_on(&l, b));
assert!(big.x1 > small.x1 + 15.0 && big.y1 > small.y1 + 7.0, "{small:?} → {big:?}");
// A 300% head on a 2 pt stroke is 24 pt long and wide: its tip, past the end, is inside.
assert!(big.x1 >= 100.0 + 24.0 && big.y1 >= 12.0 && big.x0 == -1.0, "{big:?}");
assert!(big.x1 >= 100.0 + 24.0 && big.y1 >= 12.0 && big.x0 == 0.0, "{big:?}");
// The start has no head.
assert_eq!(at(300.0).head_reach(false), 0.0);
}
@@ -405,6 +533,164 @@ mod tests {
assert_eq!(stroke(4.0, |s| s.align = StrokeAlign::Outside).bounds_on(&sq, b), b.inflate(4.0, 4.0));
}
/// An open path through `points`.
fn polyline(points: &[(f64, f64)]) -> BezPath {
let mut bp = BezPath::new();
for (i, p) in points.iter().enumerate() {
if i == 0 { bp.move_to(*p) } else { bp.line_to(*p) }
}
bp
}
#[test]
fn butt_caps_stop_at_the_ends_of_an_open_path() {
let butt = |f: fn(&mut StrokeLayer)| {
stroke(2.0, |s| {
s.cap = LineCap::Butt;
f(s);
})
};
// A vertical line reaches sideways only.
let v = polyline(&[(50.0, 10.0), (50.0, 60.0)]);
assert_eq!(butt(|_| {}).bounds_on(&v, v.bounding_box()), Rect::new(49.0, 10.0, 51.0, 60.0));
// A 3-4-5 diagonal: its corners reach 0.8 pt across x and 0.6 pt across y.
let d = polyline(&[(0.0, 0.0), (30.0, 40.0)]);
let b = butt(|_| {}).bounds_on(&d, d.bounding_box());
for (got, want) in [(b.x0, -0.8), (b.y0, -0.6), (b.x1, 30.8), (b.y1, 40.6)] {
assert!((got - want).abs() < 1e-9, "{b:?}");
}
// The dashes of a butt stroke stop inside the line as well, and an open path ignores the
// alignment.
let dashed = butt(|s| s.dash = Some(Dash { pattern: vec![4.0, 2.0], offset: 0.0, align_corners: false }));
assert_eq!(dashed.bounds_on(&v, v.bounding_box()), Rect::new(49.0, 10.0, 51.0, 60.0));
assert_eq!(butt(|s| s.align = StrokeAlign::Inside).bounds_on(&v, v.bounding_box()), Rect::new(49.0, 10.0, 51.0, 60.0));
// A round cap reaches half the weight past each end, a square one the corners.
let round = butt(|s| s.cap = LineCap::Round).bounds_on(&v, v.bounding_box());
assert_eq!(round, Rect::new(49.0, 9.0, 51.0, 61.0));
let square = butt(|s| s.cap = LineCap::Square).bounds_on(&v, v.bounding_box());
assert_eq!(square, Rect::new(49.0, 9.0, 51.0, 61.0));
// Two strokes union.
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0);
ap.stroke_mut().unwrap().cap = LineCap::Butt;
let wide = stroke(10.0, |s| s.cap = LineCap::Round);
ap.items.push(crate::AppearanceItem::Stroke(wide));
let vb = v.bounding_box();
assert_eq!(ap.stroked_bounds(vb, || v.clone()), Rect::new(45.0, 5.0, 55.0, 65.0));
}
#[test]
fn a_butt_paths_joins_and_curves_stay_inside_the_box() {
// A corner in the middle: the round join reaches a full half weight straight down, outside
// the turn, the ends do not, and a miter reaches its tip.
let zig = polyline(&[(0.0, 0.0), (20.0, 20.0), (40.0, 0.0)]);
let round = stroke(4.0, |s| {
s.cap = LineCap::Butt;
s.join = LineJoin::Round;
})
.bounds_on(&zig, zig.bounding_box());
assert!((round.y1 - 22.0).abs() < 1e-9, "the round join's reach: {round:?}");
assert!((round.x0 + 2.0_f64.sqrt()).abs() < 1e-9 && (round.x1 - 40.0 - 2.0_f64.sqrt()).abs() < 1e-9, "the ends' corners: {round:?}");
// A round join paints its arc on the outer side of a turn too shallow to count as a
// corner, and nothing where the path goes straight on.
let round_butt = stroke(4.0, |s| {
s.cap = LineCap::Butt;
s.join = LineJoin::Round;
});
let shallow = polyline(&[(0.0, 0.25), (50.0, 0.0), (100.0, 0.25)]);
let b = round_butt.bounds_on(&shallow, shallow.bounding_box());
assert!((b.y0 + 2.0).abs() < 1e-12, "{b:?}");
let straight = polyline(&[(0.0, 0.0), (0.1, 0.0), (100.0, 0.0)]);
assert_eq!(round_butt.bounds_on(&straight, straight.bounding_box()), Rect::new(0.0, -2.0, 100.0, 2.0));
let miter = stroke(4.0, |s| s.cap = LineCap::Butt).bounds_on(&zig, zig.bounding_box());
assert!((miter.y1 - (20.0 + 2.0 * 2.0_f64.sqrt())).abs() < 1e-9, "the miter tip: {miter:?}");
// An arc over the top whose ends leave the page straight down: the top reaches half the
// weight past the path, and the bottom stops at the ends.
let mut arc = BezPath::new();
arc.move_to((0.0, 50.0));
arc.curve_to((0.0, 22.4), (22.4, 0.0), (50.0, 0.0));
arc.curve_to((77.6, 0.0), (100.0, 22.4), (100.0, 50.0));
let b = stroke(4.0, |s| s.cap = LineCap::Butt).bounds_on(&arc, arc.bounding_box());
assert!((b.y0 + 2.0).abs() < 1e-9 && (b.x0 + 2.0).abs() < 1e-9 && (b.x1 - 102.0).abs() < 1e-9, "{b:?}");
assert!((b.y1 - 50.0).abs() < 1e-9, "{b:?}");
}
#[test]
fn a_butt_stroke_is_slanted_where_a_segment_starts_or_ends_at_a_slant() {
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0);
ap.stroke_mut().unwrap().cap = LineCap::Butt;
let id = vectorcraft_geom::Affine::IDENTITY;
assert!(!ap.has_slanted_butt_stroke(&polyline(&[(0.0, 0.0), (0.0, 50.0), (30.0, 50.0)]), id));
// Square ends, slanted segments in the middle.
let apex = polyline(&[(0.0, 50.0), (0.0, 40.0), (10.0, 0.0), (20.0, 40.0), (20.0, 50.0)]);
assert!(ap.has_slanted_butt_stroke(&apex, id));
// A rotation slants a vertical line; a round cap is never slanted.
let v = polyline(&[(0.0, 0.0), (0.0, 50.0)]);
assert!(ap.has_slanted_butt_stroke(&v, vectorcraft_geom::Affine::rotate(0.3)));
ap.stroke_mut().unwrap().cap = LineCap::Round;
assert!(!ap.has_slanted_butt_stroke(&apex, id));
}
#[test]
fn the_slant_check_looks_into_at_most_4096_symbol_instances() {
// S0 is a vertical butt line. Each of S1 to S12 holds two instances of the symbol before
// it, so an instance of S12 holds 8,191 instances and none of them is slanted. Past 4,096
// the check answers yes.
let mut d = Document::new(400.0, 400.0);
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0);
ap.stroke_mut().unwrap().cap = LineCap::Butt;
let line = Node::path(d.alloc_id(), PathData::from_bezpath(&polyline(&[(0.0, 0.0), (0.0, 10.0)])), ap);
d.symbols.push(crate::Symbol { dynamic: false, name: "S0".into(), art: std::sync::Arc::new(line) });
for k in 1..=12 {
let mut instance = |dx: f64| {
let xf = vectorcraft_geom::Affine::translate((dx, 0.0));
std::sync::Arc::new(Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: format!("S{}", k - 1), xf }))
};
let pair = vec![instance(0.0), instance(20.0)];
let art = Node::group(d.alloc_id(), pair);
d.symbols.push(crate::Symbol { dynamic: false, name: format!("S{k}"), art: std::sync::Arc::new(art) });
}
let top = Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: "S12".into(), xf: vectorcraft_geom::Affine::IDENTITY });
let symbols = d.symbol_boxes();
assert!(top.has_slanted_butt_stroke(&symbols, vectorcraft_geom::Affine::IDENTITY));
assert!(symbols.art_measured() <= SLANT_INSTANCES, "{}", symbols.art_measured());
// One instance of S1 holds three instances, all looked into.
let one = Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: "S1".into(), xf: vectorcraft_geom::Affine::IDENTITY });
assert!(!one.has_slanted_butt_stroke(&d.symbol_boxes(), vectorcraft_geom::Affine::IDENTITY));
}
#[test]
fn a_compound_path_measures_each_of_its_subpaths_by_its_own_ends() {
// A closed square at the top and an open line below it: only the square reaches past its
// own edges.
let mut bp = BezPath::new();
bp.extend(square().elements().iter().copied());
bp.move_to((50.0, 150.0));
bp.line_to((50.0, 200.0));
let st = stroke(4.0, |s| s.cap = LineCap::Butt);
assert_eq!(st.bounds_on(&bp, bp.bounding_box()), Rect::new(-2.0, -2.0, 102.0, 200.0));
}
#[test]
fn a_middle_segment_can_set_the_box_of_a_butt_path() {
// The flat middle segment reaches a full half weight above the path, and the ends' corners
// reach 10/51 of it below.
let hat = polyline(&[(0.0, 50.0), (10.0, 0.0), (20.0, 0.0), (30.0, 50.0)]);
let b = stroke(2.0, |s| {
s.cap = LineCap::Butt;
s.join = LineJoin::Bevel;
})
.bounds_on(&hat, hat.bounding_box());
let corner = 10.0 / (10.0_f64 * 10.0 + 50.0 * 50.0).sqrt();
assert!((b.y0 + 1.0).abs() < 1e-9 && (b.y1 - 50.0 - corner).abs() < 1e-9, "{b:?}");
}
#[test]
fn bounds_with_tips_measures_a_butt_line_as_bounds_on_does() {
let v = polyline(&[(50.0, 10.0), (50.0, 60.0)]);
let st = stroke(2.0, |s| s.cap = LineCap::Butt);
assert_eq!(st.bounds_with_tips(&v, v.bounding_box(), None), Rect::new(49.0, 10.0, 51.0, 60.0));
}
#[test]
fn clicks_hit_outside_strokes_on_their_side_only() {
let sq = square();
+22
View File
@@ -56,6 +56,28 @@ fn check(bp: &BezPath, st: &StrokeLayer, what: &str) {
assert!(checked > 0, "{what}: no samples");
}
#[test]
fn a_butt_capped_open_path_is_measured_where_it_paints() {
let mut vertical = BezPath::new();
vertical.move_to((50.0, 10.0));
vertical.line_to((50.0, 90.0));
let mut diagonal = BezPath::new();
diagonal.move_to((10.0, 10.0));
diagonal.line_to((40.0, 50.0));
let mut arc = BezPath::new();
arc.move_to((10.0, 50.0));
arc.curve_to((10.0, 22.4), (32.4, 0.0), (60.0, 0.0));
let mut stroke = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0);
stroke.cap = LineCap::Butt;
for (name, bp) in [("vertical", vertical), ("diagonal", diagonal), ("arc", arc)] {
let painted = outline_region(&PathData::from_bezpath(&bp), FillRule::NonZero, &stroke).to_bezpath().bounding_box();
let bounds = stroke.bounds_on(&bp, bp.bounding_box());
for (got, want) in [(bounds.x0, painted.x0), (bounds.y0, painted.y0), (bounds.x1, painted.x1), (bounds.y1, painted.y1)] {
assert!((got - want).abs() < 0.05, "{name}: measured {bounds:?}, paints {painted:?}");
}
}
}
#[test]
fn bounds_and_clicks_cover_what_every_stroke_paints() {
let profiles = [None, Some(WidthProfile::lens()), Some(WidthProfile { points: vec![(0.0, 2.0, 0.5), (0.6, 0.8, 1.6), (1.0, 1.2, 0.3)] })];
+2 -1
View File
@@ -115,7 +115,8 @@ pub(crate) fn subpath_selected_segments(
let n = sp.anchors.len();
let mut selected_segs = BTreeSet::new();
let has_explicit_segs = seg_refs.is_some_and(|segments| segments.iter().any(|&(s, _)| s == si));
// Segments picked anywhere on the path leave anchors out of it, as Clear does.
let has_explicit_segs = seg_refs.is_some_and(|segments| !segments.is_empty());
if has_explicit_segs {
if let Some(segments) = seg_refs {
for &(s, seg) in segments {
+5 -1
View File
@@ -1063,8 +1063,12 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result<Value> {
let cuts = super::clipboard::subpath_selected_segments(&sp, si, seg_refs, anchor_refs);
if cuts.is_empty() {
subpaths.push(sp);
} else {
} else if seg_refs.is_some_and(|segments| !segments.is_empty()) {
subpaths.extend(super::path::without_segments(sp, &cuts));
} else {
// Picked anchors leave the path with their connections, as Clear removes them.
let gone: BTreeSet<usize> = anchor_refs.into_iter().flatten().filter_map(|&(s, a)| (s == si).then_some(a)).collect();
subpaths.extend(super::path::without_anchors(sp, &gone));
}
}
subpaths.retain(|sp| sp.anchors.len() >= 2);
+9 -4
View File
@@ -125,15 +125,20 @@ fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64, anchor
// The scales and the point above can miss the size or the reference point when the margins of
// the objects differ, the members of groups and the objects in symbols' art included, and with
// strokes that scale, when the sizes are out of proportion. The objects are then measured one
// by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects are
// measured as drawn after each scale tried instead ([`refit_drawn`]).
// by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects, and
// objects with a butt-capped stroke along a segment at a slant, whose margins change with the
// segment's direction, are measured as drawn after each scale tried instead ([`refit_drawn`]).
let symbols = d.symbol_boxes();
let drawn = || ids.iter().filter_map(|id| d.node(*id)).any(|n| effects::preview::draws_geometry(&symbols, n));
let to_local = frame.to_doc().inverse();
let drawn = || {
ids.iter()
.filter_map(|id| d.node(*id))
.any(|n| effects::preview::draws_geometry(&symbols, n) || n.has_slanted_butt_stroke(&symbols, to_local))
};
let solved = if !(s.prefs.use_preview_bounds && w >= 0.0 && h >= 0.0) {
None
} else if transforms_objects(p) && !bool_or(p, "typeAreas", false) && drawn() {
let options = scaling_options(s, p);
let to_local = frame.to_doc().inverse();
let measure = |sx: f64, sy: f64| {
let xf = frame.conjugate(about(rp, Affine::scale_non_uniform(sx, sy)));
let sc = if Scaling::factor(xf).is_some() { options } else { Scaling::default() };
+1 -1
View File
@@ -1329,7 +1329,7 @@ pub(super) fn replace_deleted_pieces(d: &mut Document, beside: &mut Beside, orig
/// Remove anchors and their incident segments, keeping each surviving stretch as an open path.
/// Only a closed path can have a surviving stretch that wraps past the last anchor.
fn without_anchors(sp: SubPath, removed: &BTreeSet<usize>) -> Vec<SubPath> {
pub(super) fn without_anchors(sp: SubPath, removed: &BTreeSet<usize>) -> Vec<SubPath> {
let n = sp.anchors.len();
let gone: BTreeSet<usize> = removed.iter().copied().filter(|&i| i < n).collect();
if gone.is_empty() {
+4 -8
View File
@@ -2698,19 +2698,15 @@ fn pen_cmd_selected_anchors_are_what_copy_and_cut_take() {
assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 1), (0, 2)].into());
s.execute("edit.copy", &json!({})).unwrap();
assert_eq!(pasted(&mut s), [2]);
// Cut takes the segment between them. The two surviving runs of an ordinary path
// become independent objects, and this path keeps the first run (#3380).
// Cut takes the two anchors out of the path, as Clear does (#6361): each side keeps one
// anchor, which makes no path, so nothing is left.
s.execute("edit.undo", &json!({})).unwrap();
let original = path(&s, id);
let picked = s.doc().unwrap().selection.clone();
s.execute("edit.cut", &json!({})).unwrap();
let left = paths(&s);
assert_eq!(left.len(), 2);
assert_eq!(left[0].0, id);
assert!(left.iter().all(|(_, p)| p.subpaths.len() == 1 && !p.subpaths[0].closed));
assert_eq!(left[0].1.subpaths[0].anchors, original.subpaths[0].anchors[..2]);
assert_eq!(left[1].1.subpaths[0].anchors, original.subpaths[0].anchors[2..]);
assert_eq!(s.doc().unwrap().selection.objects, left.iter().map(|(id, _)| *id).collect::<Vec<_>>());
assert!(left.is_empty());
assert!(s.doc().unwrap().selection.objects.is_empty());
assert_eq!(s.clipboard.nodes.len(), 1);
assert_eq!(s.clipboard.nodes[0].path_data().unwrap().subpaths[0].anchors, original.subpaths[0].anchors[1..3]);
s.execute("edit.undo", &json!({})).unwrap();
+71
View File
@@ -618,6 +618,77 @@ fn set_bounds_leaves_the_height_of_lines_with_no_height() {
assert!(close(geometric(&s, ids[1]), Rect::new(8.3, 100.0, 30.0, 100.0)), "{:?}", geometric(&s, ids[1]));
}
/// A line with a 2 pt stroke and butt caps, selected, with Use Preview Bounds on.
fn butt_line(s: &mut Session, from: (f64, f64), to: (f64, f64)) -> NodeId {
let id = NodeId(run(s, "shape.line", json!({"x1": from.0, "y1": from.1, "x2": to.0, "y2": to.1}))["id"].as_u64().unwrap());
run(s, "stroke.set", json!({"weight": 2, "cap": "butt"}));
preview(s, true);
run(s, "select.set", json!({"ids": [id.0]}));
id
}
#[test]
fn set_bounds_gives_a_butt_line_the_whole_height() {
// A butt cap ends at the anchor, so 10 mm (28.3465 pt) is the length of the line itself.
let mut s = session();
let id = butt_line(&mut s, (100.0, 100.0), (100.0, 140.0));
assert!(close(s.transform_bounds(&[id]).unwrap(), Rect::new(99.0, 100.0, 101.0, 140.0)), "{:?}", s.transform_bounds(&[id]));
run(&mut s, "object.setBounds", json!({"height": 28.3465, "reference": 0, "strokes": false}));
assert!(close(geometric(&s, id), Rect::new(100.0, 100.0, 100.0, 128.3465)), "{:?}", geometric(&s, id));
assert!(close(s.transform_bounds(&[id]).unwrap(), Rect::new(99.0, 100.0, 101.0, 128.3465)), "{:?}", s.transform_bounds(&[id]));
}
#[test]
fn set_bounds_gives_a_diagonal_butt_line_the_size_out_of_proportion() {
// The stroke's corners follow the line's slope, which a scale out of proportion changes.
let mut s = session();
let id = butt_line(&mut s, (100.0, 100.0), (130.0, 140.0));
let before = s.transform_bounds(&[id]).unwrap();
// The stroke's corners reach 0.8 pt across x and 0.6 pt across y at this 3-4-5 slope.
assert!(close(before, Rect::new(99.2, 99.4, 130.8, 140.6)), "{before:?}");
run(&mut s, "object.setBounds", json!({"height": 100, "reference": 0, "strokes": false}));
let v = s.transform_bounds(&[id]).unwrap();
assert!((v.height() - 100.0).abs() < 1e-6, "{v:?}");
}
#[test]
fn set_bounds_gives_a_butt_path_with_slanted_corners_the_size_out_of_proportion() {
// Square ends, but the bevel join at the apex sets the top edge by the slanted segments'
// corners, which a scale out of proportion moves.
let mut s = session();
let anchors = json!([{"x": 100, "y": 150}, {"x": 100, "y": 140}, {"x": 110, "y": 100}, {"x": 120, "y": 140}, {"x": 120, "y": 150}]);
let id = NodeId(run(&mut s, "path.create", json!({"anchors": anchors}))["id"].as_u64().unwrap());
run(&mut s, "stroke.set", json!({"weight": 2, "cap": "butt", "join": "bevel"}));
preview(&mut s, true);
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "object.setBounds", json!({"width": 60, "height": 20, "reference": 0, "strokes": false}));
let v = s.transform_bounds(&[id]).unwrap();
assert!((v.width() - 60.0).abs() < 1e-6 && (v.height() - 20.0).abs() < 1e-6, "{v:?}");
}
#[test]
fn set_bounds_gives_a_symbol_instance_of_a_diagonal_butt_line_the_size_out_of_proportion() {
let mut s = session();
butt_line(&mut s, (100.0, 100.0), (130.0, 140.0));
let id = NodeId(run(&mut s, "symbol.new", json!({"name": "Line"}))["id"].as_u64().unwrap());
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "object.setBounds", json!({"width": 20, "height": 100, "reference": 0, "strokes": false}));
let v = s.transform_bounds(&[id]).unwrap();
assert!((v.width() - 20.0).abs() < 1e-6 && (v.height() - 100.0).abs() < 1e-6, "{v:?}");
}
#[test]
fn align_places_a_vertical_butt_line_by_its_end_points() {
// With Use Preview Bounds on, a butt line's ends line up with the top of a rectangle without
// a stroke.
let mut s = session();
rect(&mut s, 20.0, 50.0, 0.0);
let id = butt_line(&mut s, (200.0, 160.0), (200.0, 190.0));
run(&mut s, "select.all", json!({}));
run(&mut s, "object.align", json!({"vertical": "top"}));
assert!(close(geometric(&s, id), Rect::new(200.0, 100.0, 200.0, 130.0)), "{:?}", geometric(&s, id));
}
/// The rectangles of [`thin_and_thick`] in a group, the thick one in a group of its own inside it
/// when `nested`, selected with an unstroked rectangle from x 100 to 110 when `plain`. Returns the
/// selected objects.
@@ -192,7 +192,7 @@ fn pen_tool_drawing_copies_whole_path_not_single_anchor() {
}
#[test]
fn cut_adjacent_anchors_clipboard_plus_remaining_equals_original() {
fn cut_adjacent_anchors_takes_them_out_of_the_source() {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
let r = rect(&mut s, 10.0, 10.0, 100.0, 50.0);
@@ -207,15 +207,93 @@ fn cut_adjacent_anchors_clipboard_plus_remaining_equals_original() {
assert_eq!(clip_path.subpaths[0].segment_count(), 1);
assert_eq!(clip_path.subpaths[0].anchors.len(), 2);
// Remaining source path must have the remaining 3 segments (segments 0, 2, 3), not 1.
// The source keeps anchors 3 and 0 and the one segment between them.
let src_node = s.doc().unwrap().doc.node(r).unwrap();
let src_path = src_node.path_data().unwrap();
assert_eq!(src_path.subpaths[0].segment_count(), 3);
assert_eq!(src_path.subpaths[0].anchors.len(), 4);
assert_eq!(src_path.subpaths[0].segment_count(), 1);
assert_eq!(src_path.subpaths[0].anchors.len(), 2);
assert!(!src_path.subpaths[0].closed);
}
// Sum of segments in clipboard + remaining = 1 + 3 = 4 (the original 4 segments).
assert_eq!(clip_path.subpaths[0].segment_count() + src_path.subpaths[0].segment_count(), 4);
/// Every contour left in the document after `command` runs on anchors `picked` of a four-anchor
/// open path, as `[x, y]` lists. With `compound`, the path is a member of a compound path.
fn contours_after(command: &str, picked: Value, compound: bool) -> Vec<Vec<[f64; 2]>> {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
let id = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 10, "y": 20}]}))["id"].as_u64().unwrap());
run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 60, "y": 20}));
run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 100, "y": 80}));
run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 150, "y": 80}));
if compound {
let other = rect(&mut s, 10.0, 100.0, 40.0, 40.0);
run(&mut s, "select.set", json!({"ids": [id.0, other.0]}));
run(&mut s, "object.compoundPath.make", json!({}));
}
run(&mut s, "select.anchors", json!({"id": id.0, "anchors": picked, "mode": "set"}));
run(&mut s, command, json!({}));
check_session(&s).unwrap();
contours(&s)
}
/// Every contour in the document, as `[x, y]` lists.
fn contours(s: &Session) -> Vec<Vec<[f64; 2]>> {
let mut left = vec![];
for layer in &s.doc().unwrap().doc.layers {
layer.walk(&mut |n| {
if let Some(path) = n.path_data() {
left.extend(path.subpaths.iter().map(|sp| sp.anchors.iter().map(|a| [a.p.x, a.p.y]).collect::<Vec<_>>()));
}
});
}
left
}
#[test]
fn cut_end_anchors_leaves_the_source_as_clear_does() {
for compound in [false, true] {
let picked = || json!([[0, 0], [0, 1]]);
let left = contours_after("edit.cut", picked(), compound);
assert_eq!(left, contours_after("edit.clear", picked(), compound), "compound: {compound}");
assert_eq!(left[0], [[100.0, 80.0], [150.0, 80.0]], "compound: {compound}");
}
}
#[test]
fn cut_interior_anchors_leaves_the_source_as_clear_does() {
let picked = || json!([[0, 1], [0, 2]]);
let left = contours_after("edit.cut", picked(), true);
assert_eq!(left, contours_after("edit.clear", picked(), true));
assert!(left.iter().flatten().all(|p| p[0] != 60.0 && p[0] != 100.0), "the picked anchors are gone: {left:?}");
}
#[test]
fn cut_one_end_anchor_of_a_compound_member_shortens_it() {
let left = contours_after("edit.cut", json!([[0, 3]]), true);
assert_eq!(left[0], [[10.0, 20.0], [60.0, 20.0], [100.0, 80.0]]);
}
#[test]
fn cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does() {
let after = |command: &str| {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 400, "height": 400}));
let id = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 10, "y": 20}]}))["id"].as_u64().unwrap());
let contour = |y: i32| json!({"anchors": [{"x": 10, "y": y}, {"x": 60, "y": y}, {"x": 110, "y": y}], "closed": false});
run(&mut s, "path.setAnchors", json!({"id": id.0, "subpaths": [contour(20), contour(120)]}));
run(&mut s, "select.anchors", json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}));
run(&mut s, "select.segments", json!({"id": id.0, "segments": [[1, 1]], "mode": "add"}));
run(&mut s, command, json!({}));
check_session(&s).unwrap();
s
};
let cut = after("edit.cut");
assert_eq!(contours(&cut), contours(&after("edit.clear")));
assert!(contours(&cut).contains(&vec![[10.0, 20.0], [60.0, 20.0], [110.0, 20.0]]), "the picked anchor stays: {:?}", contours(&cut));
// The clipboard holds what left the source: the picked segment alone.
assert_eq!(cut.clipboard.nodes.len(), 1);
let clip = cut.clipboard.nodes[0].path_data().unwrap();
assert_eq!(clip.subpaths.len(), 1);
assert_eq!(clip.subpaths[0].anchors.iter().map(|a| [a.p.x, a.p.y]).collect::<Vec<_>>(), [[60.0, 120.0], [110.0, 120.0]]);
}
#[test]
+1 -1
View File
@@ -145,7 +145,7 @@ fn a_file_an_importer_reads_opens_as_editable_art() {
let stroked = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,-4000000]},{"p":[50,-4000000]}]}]},"strokes":[{"paint":{"type":"solid","color":{"model":"rgb","r":0,"g":0,"b":0}},"width":10}]}]}"#;
open(&mut s, "Stroked.tso", stroked.as_bytes(), json!({})).unwrap();
let (x0, y0, x1, y1) = artboard(&s);
assert!(x0 < 0.0 && x1 > 50.0 && y0 == -4_000_000.0 && y1 > y0 + 1.0, "{:?}", artboard(&s));
assert!(x0 == 0.0 && x1 == 50.0 && y0 == -4_000_000.0 && y1 > y0 + 1.0, "{:?}", artboard(&s));
s.execute("select.all", &json!({})).unwrap();
s.execute("object.move", &json!({"dx": 0, "dy": 5})).unwrap();
+1 -1
View File
@@ -133,7 +133,7 @@ impl Space {
}
/// A named colour or ink's full colour: `[c m y k /DeviceCMYK (name) /kind]` or `[r g b /DeviceRGB …]`.
fn ink(a: &[V]) -> Option<Color> {
pub(super) fn ink(a: &[V]) -> Option<Color> {
let at = a.iter().position(|v| matches!(v, V::Name(_)))?;
let nums: Vec<f32> = a.get(..at)?.iter().map(|v| v.num().filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0) as f32)).collect::<Option<_>>()?;
match (a.get(at).and_then(V::text).as_deref(), nums.as_slice()) {
+31
View File
@@ -72,6 +72,37 @@ pub(super) fn color_op_in(op: &str, vals: &[V], names: &mut Names) -> Option<(Pa
}
}
/// `n [c m y k /DeviceCMYK (name) /Separation] XC` (or `[r g b /DeviceRGB …]`) before an image: its
/// samples are one ink's tints. The ink at 100 % as a paint, with the named colour that makes its
/// spot swatch (none for Registration). `None` for a device space (`/DeviceGray XC`), the ink
/// `None`, which paints nothing, or another form.
pub(super) fn separation_ink(vals: &[V], names: &mut Names) -> Option<(Paint, Option<Named>)> {
let a = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { Some(a) } else { None })?;
let [.., name @ V::Str(_), V::Name(kind)] = a.as_slice() else { return None };
if kind.trim_start_matches('/') != "Separation" {
return None;
}
let color = super::mesh::ink(a)?;
let name = names.text(name)?;
if name == "None" {
return None;
}
if is_registration(&name) {
let paint = Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(vectorcraft_color::swatch::REGISTRATION.into()), tint: 1.0 };
return Some((paint, None));
}
// A process plate is its CMYK colour, not a spot ink: no swatch of its name.
if let Some(plate) = ["Cyan", "Magenta", "Yellow", "Black"].iter().position(|p| *p == name) {
let mut cmyk = [0.0; 4];
if let Some(v) = cmyk.get_mut(plate) {
*v = 1.0;
}
return Some((Paint::solid(Color::cmyk(cmyk[0], cmyk[1], cmyk[2], cmyk[3])), None));
}
let paint = Paint::Solid { color, swatch: Some(name.clone()), tint: 1.0 };
Some((paint, Some(Named { name, color, tint: 1.0, spot: true })))
}
fn is_registration(name: &str) -> bool {
matches!(name, vectorcraft_color::swatch::REGISTRATION | "Registration" | "/All" | "All" | "/[Registration]")
}
+50 -11
View File
@@ -472,10 +472,34 @@ impl<'a> Reader<'a> {
return Ok(());
};
let opacity = opacity.filter(|_| alpha_len > 0);
// An image in one ink's Separation space (`XC`): its samples are the ink's tints, the most
// at 100 % (255, or a set bit). It comes in as a grayscale image of them, black at 100 %,
// that the ink colorizes, as a Separation image opens from a PDF; its alpha channel
// becomes its opacity mask.
let ink = if channels == 1 && !image_mask { self.raster.as_mut().and_then(|r| r.ink.take()) } else { None };
let inverted;
let samples = if ink.is_some() {
inverted = samples.iter().map(|b| !b).collect::<Vec<u8>>();
inverted.as_slice()
} else {
samples
};
let ink = ink.map(|(paint, named)| {
// Its colour counts toward the document's colour model, as a fill's does (Registration
// says nothing of it).
let registration = matches!(&paint, Paint::Solid { swatch: Some(s), .. } if s == vectorcraft_color::swatch::REGISTRATION);
match paint.color().filter(|_| !registration) {
Some(vectorcraft_color::Color::Rgb { .. }) => self.rgb_colors += 1,
Some(vectorcraft_color::Color::Cmyk { .. }) => self.cmyk_colors += 1,
_ => {}
}
self.spots.extend(named);
paint
});
let gray1 = *bits == 1.0 && channels == 1 && opacity.is_none();
if k > 1 || w * h > raster::SMALL || (gray1 && !image_mask) {
// An image mask keeps the color that colorizes it.
let colorize = (image_mask && channels == 1 && *bits == 1.0).then(|| self.mask_paint());
// An image mask keeps the color that colorizes it, an ink image its ink.
let colorize = if image_mask && channels == 1 && *bits == 1.0 { Some(self.mask_paint()) } else { ink };
return self.large_image(samples, opacity, shape, k, &m, colorize);
}
// CMYK samples keep their ink amounts as a CMYK TIFF, as the PDF importer keeps a CMYK
@@ -487,7 +511,11 @@ impl<'a> Reader<'a> {
return self.add_raster(blob, opacity, &m, (w as u32, h as u32), None);
}
if image_mask && channels == 1 && *bits == 1.0 {
return self.colorized_raster(samples, row, opacity, &m, (w as u32, h as u32));
let paint = self.mask_paint();
return self.colorized_raster(samples, (row, 1), opacity, &m, (w as u32, h as u32), paint);
}
if let Some(paint) = ink {
return self.colorized_raster(samples, (row, *bits as u8), opacity, &m, (w as u32, h as u32), paint);
}
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
for y in 0..h as usize {
@@ -526,15 +554,24 @@ impl<'a> Reader<'a> {
self.add_raster(ImageBlob::new("image/png", png), None, &m, (w as u32, h as u32), None)
}
/// A 1-bit gray image whose ImageMask is 1 (`samples`, `row` bytes a row): a grayscale image
/// colorized with the fill color. Its 0 bits, which open black with ImageMask 0, take that color,
/// and its 1 bits are transparent. A fill that isn't a solid color colorizes it black.
fn colorized_raster(&mut self, samples: &[u8], row: usize, alpha: Option<&[u8]>, m: &[f64], (w, h): (u32, u32)) -> Result<(), String> {
/// A one-channel image (`samples` of `bits` bits, `row` bytes a row) as a grayscale image
/// colorized with `paint`: a 1-bit gray image whose ImageMask is 1, with the fill color (its 0
/// bits, which open black with ImageMask 0, take that color, and its 1 bits are transparent; a
/// fill that isn't a solid color colorizes it black), or an ink's tints, inverted, with the ink.
fn colorized_raster(
&mut self,
samples: &[u8],
(row, bits): (usize, u8),
alpha: Option<&[u8]>,
m: &[f64],
(w, h): (u32, u32),
paint: Paint,
) -> Result<(), String> {
let mut gray = Vec::with_capacity(w as usize * h as usize);
for y in 0..h as usize {
let line = samples.get(y * row..(y + 1) * row).unwrap_or_default();
for x in 0..w as usize {
let v = line.get(x / 8).map_or(0, |b| ((b >> (7 - x % 8)) & 1) * 255);
let v = if bits == 1 { line.get(x / 8).map_or(0, |b| ((b >> (7 - x % 8)) & 1) * 255) } else { line.get(x).copied().unwrap_or(0) };
gray.push(v);
}
}
@@ -544,7 +581,6 @@ impl<'a> Reader<'a> {
self.image_left_out("an image VectorCraft can't read was left out");
return Ok(());
}
let paint = self.mask_paint();
self.add_raster(ImageBlob::new("image/png", png), alpha, m, (w, h), Some(paint))
}
@@ -625,12 +661,15 @@ impl<'a> Reader<'a> {
return self.add_raster(ImageBlob::new("image/png", png), None, m, (ow, oh), colorize);
}
let cmyk = s.channels == 4;
let (pixels, mask) = raster::reduce(samples, opacity, s, k, !cmyk);
// A colorized image's alpha channel becomes its opacity mask, as at its full size: a
// grayscale image whose pixels have alpha takes only a spot color, black or white.
let inline = !cmyk && colorize.is_none();
let (pixels, mask) = raster::reduce(samples, opacity, s, k, inline);
let blob = if cmyk {
Inks::new(ow, oh, pixels).and_then(|i| ImageBlob::cmyk_tiff(&i))
} else {
use image::ImageEncoder as _;
let colour = match (s.channels, opacity.is_some()) {
let colour = match (s.channels, inline && opacity.is_some()) {
(1, false) => image::ExtendedColorType::L8,
(1, true) => image::ExtendedColorType::La8,
(_, false) => image::ExtendedColorType::Rgb8,
+6 -2
View File
@@ -181,9 +181,13 @@ impl<'a> Reader<'a> {
} else if line.starts_with("AI5_EndRaster") {
// A file `XG` named in this raster object is no later one's.
self.image_link = None;
if let Some(Raster { operands: Some(vals), mut samples, half, max, .. }) = self.raster.take() {
if let Some(Raster { operands: Some(vals), mut samples, half, max, ink, .. }) = self.raster.take() {
end_hex(&mut samples, half, max);
return self.image_from(vals, &samples, true);
// The image's ink (`XC`) stays for `image_from` to read.
self.raster = Some(Raster { ink, ..Raster::default() });
let read = self.image_from(vals, &samples, true);
self.raster = None;
return read;
}
} else if line.starts_with("AI5_BeginPlace") {
// One that is still open (no `~` ended it) isn't read.
+8
View File
@@ -361,6 +361,14 @@ impl<'a> Reader<'a> {
r.space = vals.iter().rev().find_map(V::text);
}
}
// `n [… (name) /Separation] XC`: the image's samples are that ink's tints. A device
// space (`/DeviceGray XC` after a linked image's file name) changes nothing read here.
"XC" => {
let ink = paint::separation_ink(&vals, &mut self.names);
if let Some(r) = self.raster.as_mut() {
r.ink = ink;
}
}
// Placed files.
// `[a b c d tx ty] llx lly urx ury (path) `` ` ``, three more numbers before the path in
// newer files: the version 7 form has no `%AI5_BeginPlace` before it. Before the art,
+3 -2
View File
@@ -279,6 +279,9 @@ impl BrushArtColors {
#[derive(Debug, Default)]
pub(super) struct Raster {
pub(super) space: Option<String>,
/// The ink of an image in a Separation space (`XC`): its paint at 100 % and the named colour
/// for its swatch (`paint::separation_ink`).
pub(super) ink: Option<(Paint, Option<Named>)>,
/// `XI`'s operands when its samples follow as hex in comment lines (`%FF00…`) up to
/// `%AI5_EndRaster`, the form the version 7 specification documents (§8).
pub(super) operands: Option<Vec<V>>,
@@ -370,8 +373,6 @@ pub(super) const IGNORED: &[&str] = &[
"As",
"Xd",
"Xr",
// `/DeviceGray XC` after a linked image's file name (`XG`): its colour space.
"XC",
"Xh",
"XH",
"Bc",
+102
View File
@@ -2755,6 +2755,108 @@ fn image_masks_are_colorized_with_the_fill() {
assert_eq!(pixels(&doc).0.get_pixel(0, 0).0, [255, 0, 0, 255]);
}
/// A gray image `w` × 1 (`bits` bits a pixel, `alpha` channels, data `data`) whose colour space is
/// set by `xc` (the operands of `XC`) instead of `/DeviceGray XN`.
fn ink_image(xc: &str, bits: u32, alpha: u32, w: u32, data: &[u8]) -> Vec<u8> {
let mut out = image("[ 1 0 0 1 0 100 ]", (w, 1), ("DeviceGray", 1, alpha), data, data.len() + 3);
// In place, as bytes: the samples aren't text.
let mut swap = |from: &[u8], to: &[u8]| {
let at = out.windows(from.len()).position(|s| s == from).unwrap();
out.splice(at..at + from.len(), to.iter().copied());
};
swap(b"/DeviceGray XN\n", format!("{xc} XC\n").as_bytes());
swap(format!(" 8 1 {alpha} 0 1 0 ").as_bytes(), format!(" {bits} 1 {alpha} 0 1 0 ").as_bytes());
out
}
/// An image in one ink's Separation space (`n [… (name) /Separation] XC`): its samples are the
/// ink's tints (255 at 100 %), so it opens as a grayscale image of them, black at 100 %, colorized
/// by the ink's spot swatch; its alpha channel becomes its opacity mask.
#[test]
fn separation_images_are_colorized_with_their_ink() {
let black = "1 [0 0 0 1 /DeviceCMYK (Black Ink) /Separation]";
let r = read(&ink_image(black, 8, 0, 3, &[0, 128, 255])).unwrap();
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
let a = art(&r.doc);
let Some(Paint::Solid { color, swatch: Some(swatch), tint }) = a[0].extras().colorize.as_deref() else { panic!("{:?}", a[0].extras().colorize) };
assert_eq!((swatch.as_str(), *tint), ("Black Ink", 1.0));
assert_eq!(color.to_rgba8(1.0), Color::cmyk(0.0, 0.0, 0.0, 1.0).to_rgba8(1.0));
assert!(r.doc.swatch("Black Ink").is_some_and(|s| s.spot), "a spot swatch for the ink");
let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) };
assert!(r.doc.images[&im.key].gray_source().is_some_and(|g| !g.alpha));
let img = pixels(&r.doc).0;
assert_eq!((0..3).map(|x| img.get_pixel(x, 0).0[0]).collect::<Vec<_>>(), [255, 127, 0], "no ink is white, 100 % black");
// An RGB alternate gives the ink's colour; an alpha channel becomes the opacity mask.
let white = "1 [1 1 1 /DeviceRGB (White Ink) /Separation]";
let r = read(&ink_image(white, 8, 1, 2, &[255, 255, 255, 0])).unwrap();
let a = art(&r.doc);
let Some(Paint::Solid { color, swatch: Some(swatch), .. }) = a[0].extras().colorize.as_deref() else { panic!("{:?}", a[0].extras().colorize) };
assert_eq!((swatch.as_str(), color.to_rgba8(1.0)), ("White Ink", Color::rgb(1.0, 1.0, 1.0).to_rgba8(1.0)));
assert!(a[0].extras().mask.is_some(), "the alpha channel as an opacity mask");
// 1 bit a pixel: a set bit is the ink at 100 % (101).
let r = read(&ink_image(black, 1, 0, 3, b"\xa0")).unwrap();
let a = art(&r.doc);
assert!(a[0].extras().colorize.is_some());
let img = pixels(&r.doc).0;
assert_eq!((0..3).map(|x| img.get_pixel(x, 0).0[0]).collect::<Vec<_>>(), [0, 255, 0]);
// A device space, and the ink None (which paints nothing), leave the image as it was.
for xc in ["/DeviceGray", "1 [0 0 0 1 /DeviceCMYK (None) /Separation]"] {
let doc = read(&ink_image(xc, 8, 0, 2, &[0, 200])).unwrap().doc;
assert!(art(&doc)[0].extras().colorize.is_none(), "{xc}");
assert_eq!(pixels(&doc).0.get_pixel(1, 0).0, [200, 200, 200, 255], "{xc}");
}
}
/// A process plate's Separation colorizes in the plate's CMYK colour, with no swatch of its name;
/// Registration in the Registration colour.
#[test]
fn separation_images_of_process_plates_and_registration() {
let r = read(&ink_image("1 [0 0 0 1 /DeviceCMYK (Black) /Separation]", 8, 0, 2, &[0, 255])).unwrap();
assert_eq!(art(&r.doc)[0].extras().colorize.as_deref(), Some(&Paint::solid(Color::cmyk(0.0, 0.0, 0.0, 1.0))));
assert!(r.doc.swatches.iter().all(|s| !s.spot), "no spot swatch for a process plate");
let r = read(&ink_image("1 [1 1 1 1 /DeviceCMYK (All) /Separation]", 8, 0, 2, &[0, 255])).unwrap();
let swatch = art(&r.doc)[0].extras().colorize.as_deref().and_then(|p| if let Paint::Solid { swatch, .. } = p { swatch.clone() } else { None });
assert_eq!(swatch.as_deref(), Some(vectorcraft_color::swatch::REGISTRATION));
}
/// The version 7 form, with samples in hex comment lines after `XI`, keeps the image's ink.
#[test]
fn a_separation_image_with_hex_comment_lines_keeps_its_ink() {
let s = "%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n\
1 [0 0 0 1 /DeviceCMYK (Black Ink) /Separation] XC\n[ 1 0 0 1 0 100 ] 3 1 0 Xh\n\
[ 1 0 0 1 0 100 ] 0 0 3 1 3 1 8 1 0 0 0 0 XI\n%0080FF\n%AI5_EndRaster\nN\nLB\n%AI5_EndLayer--\n";
let mut data = stream("").into_bytes();
let at = data.windows(14).position(|w| w == b"%%PageTrailer\n").unwrap();
data.splice(at..at, s.bytes());
let r = read(&data).unwrap();
let a = art(&r.doc);
assert!(
matches!(a[0].extras().colorize.as_deref(), Some(Paint::Solid { swatch: Some(s), .. }) if s == "Black Ink"),
"{:?}",
a[0].extras().colorize
);
let img = pixels(&r.doc).0;
assert_eq!((0..3).map(|x| img.get_pixel(x, 0).0[0]).collect::<Vec<_>>(), [255, 127, 0]);
}
/// A Separation image reduced to fit the limits keeps its ink, and its alpha channel becomes its
/// opacity mask, as at its full size.
#[test]
fn a_reduced_separation_image_keeps_its_ink_and_its_alpha_as_a_mask() {
let samples = [255u8; 16].iter().chain([0u8; 16].iter()).copied().collect::<Vec<_>>();
let data = ink_image("1 [0 0 0 1 /DeviceCMYK (Black Ink) /Separation]", 8, 1, 16, &samples);
let r = super::with_image_limits(4, 1 << 31, 512 << 20, || read(&data).unwrap());
assert!(r.warnings.iter().any(|w| w.contains("was reduced")), "{:?}", r.warnings);
let a = art(&r.doc);
assert!(a[0].extras().colorize.is_some());
assert!(a[0].extras().mask.is_some(), "the alpha channel as an opacity mask");
let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) };
assert!(r.doc.images[&im.key].gray_source().is_some_and(|g| !g.alpha), "no alpha in the pixels");
}
#[test]
fn images_with_alpha_channels() {
// RGB samples, then the alpha channel (not counted in the data's size).
+6 -2
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (`.ai` and EPS import: a note for type in an envelope, #3179; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (`.ai` and EPS import: a note for type in an envelope, #3179; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public
@@ -223,6 +223,9 @@ private editing data. VectorCraft:
A 1-bit gray image whose `XI` or `XF` has ImageMask 1 comes in colorized by the fill color set before it,
or by black when that fill is not a solid color. VectorCraft's own rule is that the pixels that open black
with ImageMask 0 take the color and the others are transparent.
An image whose color space `XC` sets to one ink (`n [… (name) /Separation] XC`) comes in as a grayscale image of
its tints (black at 100 %) colorized by the ink (its spot swatch, or a process plate's CMYK color), its alpha channel
as its opacity mask (#6350).
- Estimate: **~65%** for real exchange with Illustrator users (open: ~75%; save: PDF-compatible, or legacy
version 7 with layers and groups; not yet tested by users who hand files back).
Closing the open-side issues: 10–20 h with users' files.
@@ -324,7 +327,8 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors,
| Date | Change | Summary |
|---|---|---|
| 2026-11-10 | minor | `.ai` and EPS import: an envelope of type, which keeps its drawn art with its type hidden, now says so in the import notes ("type in an envelope comes in as the envelope's drawn art, not as editable type: the type is kept, hidden"); nothing said it before, as the other envelopes that keep their drawn art do. The type is still not editable in the envelope (part of #3179). Test `tests_native::envelope_options_keep_the_source_text_hidden_under_its_outlined_appearance` |
| 2026-11-10 | minor | `.ai` and EPS import: an envelope of type, which keeps its drawn art with its type hidden, now says so in the import notes ("type in an envelope comes in as the envelope's drawn art, not as editable type"); nothing said it before, while the other envelopes that keep their drawn art have a note. The type is still not editable in the envelope (part of #3179). Test `tests_native::envelope_options_keep_the_source_text_hidden_under_its_outlined_appearance` |
| 2026-11-09 | minor | `.ai` import: an image whose color space `XC` sets to one ink (`n [c m y k /DeviceCMYK (name) /Separation] XC`, or an RGB alternate) opened as a gray RGB image: its samples, the ink's tints (255 at 100 %), were read as gray levels, so the tints drew inverted and the ink was lost. It now opens as a grayscale image of the tints, black at 100 %, colorized by the ink at 100 %: a spot swatch, a process plate's CMYK color (no swatch of its name), or Registration; the ink's color counts toward the document's color mode as a fill's does. Its alpha channel becomes its opacity mask, a reduced large image's too: a colorized image reduced to fit the limits (an image mask included) now keeps its alpha as an opacity mask rather than in its pixels, as at its full size. 8-bit and 1-bit samples, binary or in hex comment lines. A device space (`/DeviceGray XC` after a linked image's file name) and the ink `None` change nothing, as before; linked images (`XF`) keep their placeholder. Tests `import::ai::tests::separation_images_are_colorized_with_their_ink`, `separation_images_of_process_plates_and_registration`, `a_separation_image_with_hex_comment_lines_keeps_its_ink`, `a_reduced_separation_image_keeps_its_ink_and_its_alpha_as_a_mask` (#6350) |
| 2026-11-09 | minor | PDF import: an image in a Separation color space (one ink, ISO 32000-1 §8.6.6.4) opens as a grayscale image of its tints (white where it lays no ink, black at 100 %, its Decode array applied; 1, 2, 4, 8 and 16 bits a sample, or one gray channel from its JPEG, CCITT, JBIG2 or JPEG 2000 decoder) colorized by its ink at 100 %: a spot ink's swatch, a process plate's CMYK color, or Registration. It used to open as an RGB image, losing the ink, and Fill and Recolor Artwork didn't reach its color. Its soft mask or mask becomes its opacity mask, as a CMYK image's does. The ink is found by the image's color space (kind, colorant name and tint transform), once per image. As with any colorized image, where it lays no ink the art under it shows, where the RGB image was opaque white. The ink `None`, which paints nothing, an ink whose color can't be told, an image inside an alpha soft mask, inline images and DeviceN images open as before; samples that can't be read (data cut short, a decoder's output of another layout) open in RGB with a warning, and the ink gets no swatch. Tests `colorize::a_separation_image_opens_colorized_by_its_ink`, `colorize::a_separation_image_reads_its_samples_and_decode_array`, `colorize::a_separation_image_of_a_process_plate_or_of_no_ink`, `colorize::a_separation_image_keeps_its_soft_mask`, `colorize::a_separation_image_drawn_twice_is_colorized_twice`, `colorize::a_separation_jpeg_opens_colorized_by_its_ink`, `colorize::a_separation_image_that_cant_be_read_stays_rgb` (#6314) |
| 2026-11-09 | minor | PDF import: an image mask (`/ImageMask true`, ISO 32000-1 §8.9.6.2) painted in a solid color opens as a grayscale image of what it paints (black, the rest white) colorized by that color, as the `.ai` reader opens a 1-bit image mask (#5790). It used to open as an RGB image with the color in its pixels: Fill and Recolor Artwork didn't reach the color, a spot color lost its ink, and the same mask painted again in another color took the first color, in an uncolored pattern's cell too. Its color now counts toward the document's color mode, as a fill's does. A spot color is found by its color space, as a fill's is when the page's walk names no ink: two spaces of other inks with the same alternate and tint transform can't be told apart, and the color comes in as RGB. Inside an alpha soft mask, or painted with a pattern, a mask opens as before. Tests `colorize::an_image_mask_opens_colorized_by_its_spot_color`, `colorize::an_image_mask_drawn_in_two_colors_keeps_both`, `colorize::an_uncolored_pattern_drawing_an_image_mask_keeps_each_color` (#6313) |
| 2026-11-09 | minor | PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged. Synthetic evidence: `crates/pdf/src/tests/actual_text.rs`. |
+12 -4
View File
@@ -1387,9 +1387,13 @@ vectorcraft-cli run --cmd file.new --params '{"width":600,"height":200}' --cmd
Some commands have a machine-readable parameter schema: at present `view.capsLock`, `view.saved.new`,
`view.saved.edit`, `view.saved.list`, `view.transparencyGrid` and `view.snapToGlyph.options`. The engine's
`engine.commands` list gives such a command a `schema` field: an object schema in the shape of JSON Schema 2020-12
(https://json-schema.org/draft/2020-12), with `type`, `properties`, `required`, `additionalProperties: false`,
`minimum`, `maximum`, `exclusiveMinimum` and `exclusiveMaximum` for ranges, `enum`, and `default` for a default the engine fills in. VectorCraft's own
fields are `x-since` (the API level that added the command or the parameter), `x-deprecatedSince`, `x-lenient`,
(https://json-schema.org/draft/2020-12). Its root has `$schema` (the 2020-12 dialect), `description` when the
command has a summary, and `deprecated: true` when the command is deprecated. The schema uses `type`, `properties`, `required` and
`additionalProperties: false` for objects; `minimum`, `maximum`, `exclusiveMinimum` and `exclusiveMaximum` for ranges;
`enum` for a list of names; `items` for the values of a list; `prefixItems`, `items: false` and `minItems: 2` for a
point; `anyOf` and `minItems: 3` for a color, which is a string, a list of at least three numbers or an object;
`description` for a parameter's doc text; `default` for a default the engine fills in; and `deprecated: true` for a
deprecated parameter. VectorCraft's own fields are `x-since` (the API level that added the command or the parameter), `x-deprecatedSince`, `x-lenient`,
`x-unit`, `x-kind` (`id`, `color` or `point`), `x-default` (a default the command works out, such as `toggle`),
`x-cost`, `x-writes` (`nothing`, `session`, `preferences` or `document`) and `x-returns`. A parameter that reads null
as left out is an `anyOf` with `{"type": "null"}`. Null params read as `{}`, so a schema with no required parameter has
@@ -3226,7 +3230,11 @@ the `…Open` rings…) and marks (`Bar`, `DoubleBar`, `DotOnBar`, `Slash`, `Dou
Visual bounds (Fit to Selected Art, Rasterize, exporting all art) and clicks on the canvas take
in the whole stroke as drawn: arrowheads, inside/outside alignment (an outside stroke is hit outside the path, an
inside one inside it), the width profile's width where you click, projecting caps and miter spikes up to the miter
limit. A rectangle's right-angle miters stay within half the weight of its edges.
limit. A rectangle's right-angle miters stay within half the weight of its edges. A butt-capped stroke on an open
path does not reach past the path's ends: a straight segment's box comes from the four corners of its stroked
rectangle. Past an end, only a round or projecting cap or an arrowhead adds to the box. A curve that bends more
tightly than half the stroke weight can measure a little larger than it paints, and a butt-dashed path whose first or
last dash is a gap is measured to the path's ends.
A symbol instance is measured by its symbol's art, placed by the instance's transform. The bounds in
`document.inspect` and `document.node`, the Align commands, `object.setBounds`, the bounding box, clicks, marquees,
+2
View File
@@ -579,6 +579,7 @@ since 2026-10-01.
- **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged.
- **Copy and Cut of anchors picked with a lent Direct Selection (#6123):** with the Pen or the Curvature tool, Cmd/Ctrl held lends Direct Selection for the gesture; the anchors and segments it picks are what Edit › Copy and Cut take once the key is let go, as with Direct Selection chosen, while the last anchor the Pen leaves selected as it draws still makes Copy take the whole path (#2122). A Cmd gesture that leaves the selection as it was (a Shift marquee over nothing) picks nothing. Regression test: `tests::draw2::pen_cmd_selected_anchors_are_what_copy_and_cut_take`.
- **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. When a path also has a picked segment, Cut removes and copies only its picked segments and ignores its picked anchors, on every contour, as Clear does; an anchor left without a surviving segment is removed with them. Regression tests: `cut_*_anchors_*` and `cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does` in `crates/engine/tests/it/copy_direct_selection.rs`.
- **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`.
- **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`.
- **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`.
@@ -1049,6 +1050,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Pathfinder › Trap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html), [Pathfinder panel](https://helpx.adobe.com/illustrator/using/pathfinder-panel-overview.html)): the Pathfinder panel menu's Trap… (`object.pathfinder.trap`) and Effect › Pathfinder › Trap… (`pathfinder.trap`, live on groups) add, where two objects' visible areas of different solid colours meet, a strip of the darker one's area along the edge, filled with the lighter colour at the Tint Reduction percentage and set to overprint (Reverse Traps: the darker colour spreads instead, but not a rich black); Thickness 0.01–5000 pt across vertical edges, Height/Width scales it across horizontal ones, Traps with Process Color paints a swatch colour's trap with its CMYK equivalent; the effect also has Precision and Remove Redundant Points. The panel's Trap is one undo step and groups the traps above the topmost selected object, selected; with no adjoining edges of different colours it says so and changes nothing. Tests: `crates/pathops/src/trap.rs`, `crates/effects/src/trap.rs`, `group::tests::trap_effect_adds_an_overprinting_strip_above_the_members`, `crates/engine/src/tests/trap.rs`, `panels::pathfinder::tests::trap_opens_its_options_and_ok_traps_the_selection`.
- Compound shapes ([Create compound shapes with Pathfinder](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/create-compound-shapes-with-pathfinder.html), [Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html)): the Pathfinder panel menu's Make Compound Shape (every member in Add) and Alt-clicking a Shape Mode (every member in that mode) make the two or more selected paths, compound paths, groups, compound shapes or type a live compound shape (`object.compoundShape.make`): a group whose members each keep a shape mode and stay editable, drawn, printed and exported (SVG, PDF and the rest through the baked document) as the area the modes make from the backmost member up, open paths closed, painted with its own appearance, taken from the topmost member not subtracting. With members selected (Direct Selection, Layers panel, isolation mode) a Shape Mode button sets theirs (`object.compoundShape.setMode`) and is lit for the mode they share; paint given to a member goes to the compound shape. Release Compound Shape gives the members back as they were (`object.compoundShape.release`); Expand Compound Shape and the panel's Expand button turn it into the path or compound path of its area (`object.compoundShape.expand`). Shape Modes and Pathfinders take a compound shape as its area. Saved files keep it (`compound_shape`, `shape_mode`; an unknown mode reads as Add). Blends, envelopes and objects with a warp or another geometry effect are members as they draw (a blend's steps, an envelope's distorted art, the warped outline), and the area follows an edit of the blend, envelope or warp; Release gives them back as they were. Tests: `crates/engine/src/tests/compound_shape.rs`, `group::tests::compound_shapes_draw_the_area_their_shape_modes_make`, `group::tests::nested_compound_shapes_take_part_as_their_area`, `panels::pathfinder::tests::compound_shape_items_follow_the_selection`, the `CompoundShape` op of the property-test strategies. Not yet: the question marks on mixed member modes, hit-testing by the area (a click hits the members).
- Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is measured by its glyph outlines. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy).
- A butt cap ends a stroke square at the end of an open path, and a projecting cap extends half the weight past it. VectorCraft's visual box of a butt-capped stroke on an open path does not grow past the path's ends: a straight segment's box comes from the corners of its stroked rectangle, and a curve's box comes from the stroke at its ends and where its tangent is square to an axis. Round and projecting caps and arrowheads still reach past an end. A curve that bends more tightly than half the weight can measure a little larger than it paints, and a butt-dashed path whose first or last dash is a gap is measured to the path's ends. Public reference: [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html).
- With Use Preview Bounds on, the bounding box, the Transform panel, Align, Distribute, Distribute Spacing and `object.setBounds` measure each object after its geometry effects. A path is measured by its effected shape, and a fill or stroke with geometry effects of its own is measured by the shape it paints. Type, images, live objects and symbol instances are measured by their reshaped art, and a symbol instance is also measured by its reshaped art when its symbol's art has geometry effects. A group or layer with geometry or Pathfinder effects, or with a fill or stroke of its own that has geometry effects or paints members with geometry effects, is measured by its evaluated art. A blend, envelope or repeat whose objects have geometry effects is measured by its evaluated art, with those objects measured after their effects. A 100 pt square without a stroke, with Offset Path 10, measures 120 pt, and type with Outline Object aligns and sizes by its glyphs. Raster effects are not measured. Public reference: [Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) (archived copy).
- Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. One light and approximate visibility remain ([revolve.md](revolve.md)).
- Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is still measured by its layout box. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy).