Merge remote-tracking branch 'origin/main' into fix/import-document-title

# Conflicts:
#	docs/file-format-parity.md
This commit is contained in:
Taha-Mahmoodi
2026-11-11 13:14:09 +04:30
87 changed files with 3989 additions and 208 deletions
+308 -6
View File
@@ -1,7 +1,7 @@
//! Persistent dimension recipes. Children are ordinary paths and type for all renderers/exporters.
use crate::{Document, NodeId, Unit};
use serde::{Deserialize, Serialize};
use vectorcraft_geom::{Affine, Point, Vec2};
use vectorcraft_geom::{Affine, Anchor, PathData, Point, Vec2};
/// What a dimension measures.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -306,6 +306,12 @@ pub enum DimensionSource {
a: Option<PointRef>,
b: Option<PointRef>,
vertex: Option<PointRef>,
/// The first point when it is not an anchor, held against a path's anchors.
#[serde(default, rename = "freeA", skip_serializing_if = "Option::is_none")]
free_a: Option<FreePoint>,
/// The second point when it is not an anchor.
#[serde(default, rename = "freeB", skip_serializing_if = "Option::is_none")]
free_b: Option<FreePoint>,
},
}
@@ -315,7 +321,11 @@ impl DimensionSource {
Self::Segment { edge } | Self::Arc { edge } => vec![edge.node],
Self::Angle { first, second } => vec![first.node, second.node],
Self::Circle { node } => vec![*node],
Self::Points { a, b, vertex } => [a, b, vertex].into_iter().filter_map(|p| p.as_ref().map(|r| r.node)).collect(),
Self::Points { a, b, vertex, free_a, free_b } => [a, b, vertex]
.into_iter()
.filter_map(|p| p.as_ref().map(|r| r.node))
.chain([free_a, free_b].into_iter().filter_map(|p| p.as_ref().map(FreePoint::node)))
.collect(),
}
}
/// Anchor indexes cannot safely follow a topology replacement. Ordinary moves and handle
@@ -332,6 +342,34 @@ impl DimensionSource {
old.is_some() && old != counts(after)
})
}
/// The map that took the measured art from `before` to `after`, when every path it measures
/// went through one [`art_transform`] that reflects it (a negative determinant). None when a
/// measured path is unchanged, or when two of them went through different maps.
pub fn reflection(&self, before: &Document, after: &Document) -> Option<Affine> {
let mut first: Option<Affine> = None;
for id in self.nodes() {
let (old, new) = (before.node(id)?, after.node(id)?);
if std::ptr::eq(old, new) || old.path_data() == new.path_data() {
return None;
}
let xf = fitted(before, after, id, |xf| xf.determinant() < 0.0)?;
match first {
None => first = Some(xf),
Some(f) => {
// The linear parts and the translations, each against its own size.
let (a, b) = (f.as_coeffs(), xf.as_coeffs());
let differ = |range: std::ops::Range<usize>, tolerance: f64| {
let size = a[range.clone()].iter().map(|c| c.abs()).fold(1.0, f64::max);
a[range.clone()].iter().zip(&b[range]).any(|(p, q)| (p - q).abs() > tolerance * size)
};
if differ(0..4, 1e-9) || differ(4..6, 1e-9) {
return None;
}
}
}
}
first
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -348,6 +386,235 @@ impl PointRef {
}
}
/// The point of an anchor that an [`AnchorWeight`] names: the anchor, or one of its handles.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum AnchorPart {
#[default]
Point,
In,
Out,
}
impl AnchorPart {
fn of(self, a: &Anchor) -> Point {
match self {
Self::Point => a.p,
Self::In => a.h_in,
Self::Out => a.h_out,
}
}
}
/// One anchor's share of a [`FreePoint`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AnchorWeight {
pub subpath: usize,
pub anchor: usize,
/// The anchor itself, or its in or out handle.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub part: AnchorPart,
pub weight: f64,
}
/// A measured point that is not an anchor (a point on a segment, or a point a Smart Guide gave),
/// linked to one path so that it moves with it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum FreePoint {
/// A point on a segment at curve parameter `t`, 0 at the segment's first anchor and 1 at its
/// last. It stays on the segment when the segment's anchors or handles are edited.
OnSegment { edge: SegmentRef, t: f64 },
/// A weighted sum of two or three anchors or handles of one path, the weights adding up to 1,
/// plus `across` times the vector from the first anchor to the second turned a quarter turn,
/// from (x, y) to (-y, x), so that a vector pointing right in the y-down document space
/// turns to point down (two anchors only). When the
/// path is moved, scaled, rotated, reflected or sheared, a point held by three moves with
/// it. A point held by two and `across`, beside a straight path, moves, turns and scales
/// uniformly with it, and a reflection or a non-uniform scale moves it as a half turn or a
/// uniform scale would.
Anchors {
node: NodeId,
anchors: Vec<AnchorWeight>,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
across: f64,
},
}
/// A point of a path: (subpath, anchor, part, place).
type PathPoint = (usize, usize, AnchorPart, Point);
/// Every anchor of `path` in order, each followed by its in and out handles when `handles` is set.
fn path_points(path: &PathData, handles: bool) -> impl Iterator<Item = PathPoint> + Clone + '_ {
let parts: &'static [AnchorPart] = if handles { &[AnchorPart::Point, AnchorPart::In, AnchorPart::Out] } else { &[AnchorPart::Point] };
path.subpaths
.iter()
.enumerate()
.flat_map(move |(si, sp)| sp.anchors.iter().enumerate().flat_map(move |(ai, a)| parts.iter().map(move |&part| (si, ai, part, part.of(a)))))
}
/// Three points that span `points`: the first, the one farthest from it, and the one farthest
/// from the line through those two. None when every point is in one place.
fn spanning(points: impl Iterator<Item = PathPoint> + Clone) -> Option<[PathPoint; 3]> {
let first = points.clone().next()?;
let o = first.3;
let second = points.clone().max_by(|x, y| x.3.distance(o).total_cmp(&y.3.distance(o)))?;
let u = second.3 - o;
let span = u.hypot();
if !span.is_finite() || span <= 1e-6 {
return None;
}
let third = points.max_by(|x, y| u.cross(x.3 - o).abs().total_cmp(&u.cross(y.3 - o).abs()))?;
Some([first, second, third])
}
/// The map that takes the anchors and handles of path `node` in `before` to where they are in
/// `after`, when one affine map does that for every one of them: the path was moved, scaled,
/// rotated, reflected or sheared as a whole. None when its anchors changed in number, moved in
/// another way, or lie on one line with all their handles, as on a straight line: a reflection
/// across a line through such a path puts its points where a turn can put them too.
pub fn art_transform(before: &Document, after: &Document, node: NodeId) -> Option<Affine> {
fitted(before, after, node, |_| true)
}
/// [`art_transform`] when the map passes `wanted`. The map is found from three points, and
/// `wanted` is asked before every other point is checked against it.
fn fitted(before: &Document, after: &Document, node: NodeId, wanted: impl Fn(&Affine) -> bool) -> Option<Affine> {
let (old, new) = (before.node(node)?.path_data()?, after.node(node)?.path_data()?);
if old.subpaths.len() != new.subpaths.len() || old.subpaths.iter().zip(&new.subpaths).any(|(o, n)| o.anchors.len() != n.anchors.len()) {
return None;
}
let [o, f, g] = spanning(path_points(old, true))?;
let place = |q: PathPoint| new.subpaths.get(q.0).and_then(|sp| sp.anchors.get(q.1)).map(|a| q.2.of(a));
let (o2, f2, g2) = (place(o)?, place(f)?, place(g)?);
let (u, v) = (f.3 - o.3, g.3 - o.3);
let span = u.hypot();
if u.cross(v).abs() <= 1e-9 * span * span {
return None;
}
let basis = |u: Vec2, v: Vec2| Affine::new([u.x, u.y, v.x, v.y, 0.0, 0.0]);
let linear = basis(f2 - o2, g2 - o2) * basis(u, v).inverse();
let xf = Affine::translate(o2.to_vec2()) * linear * Affine::translate(-o.3.to_vec2());
if !xf.as_coeffs().iter().all(|c| c.is_finite()) || !wanted(&xf) {
return None;
}
let tolerance = 1e-6 * span.max(1.0);
path_points(old, true).zip(path_points(new, true)).all(|(o, n)| (xf * o.3).distance(n.3) <= tolerance).then_some(xf)
}
impl FreePoint {
/// Largest weight: a point far outside the anchors that hold it is not held.
const MAX_WEIGHT: f64 = 1e4;
/// The path it is linked to.
pub fn node(&self) -> NodeId {
match self {
Self::OnSegment { edge, .. } => edge.node,
Self::Anchors { node, .. } => *node,
}
}
/// `p` held against path `node`: three anchors that span the path; when all its anchors lie
/// on one line or in one place, as on an arch, three of its anchors and handles; when its
/// handles lie on that line too, or so nearly that the weights would be too large, two anchors
/// and `p`'s distance across that line. None when none of these holds `p`.
pub fn on(doc: &Document, node: NodeId, p: Point) -> Option<Self> {
let path = doc.node(node)?.path_data()?;
let weight = |(subpath, anchor, part, _): PathPoint, weight| AnchorWeight { subpath, anchor, part, weight };
for handles in [false, true] {
let Some([o, f, g]) = spanning(path_points(path, handles)) else { continue };
let (u, v) = (f.3 - o.3, g.3 - o.3);
let span = u.hypot();
let det = u.cross(v);
if det.abs() > 1e-9 * span * span {
let s = (p - o.3).cross(v) / det;
let t = u.cross(p - o.3) / det;
let anchors = vec![weight(o, 1.0 - s - t), weight(f, s), weight(g, t)];
// A nearly flat set of points gives weights too large to keep: the next fit is tried.
if let Some(held) = (Self::Anchors { node, anchors, across: 0.0 }).holding(doc, p, span) {
return Some(held);
}
}
}
// Every anchor and handle on one line, or nearly: along it from the first anchor, and across it.
let [o, f, _] = spanning(path_points(path, false))?;
let u = f.3 - o.3;
let span = u.hypot();
let (t, across) = ((p - o.3).dot(u) / (span * span), u.cross(p - o.3) / (span * span));
Self::Anchors { node, anchors: vec![weight(o, 1.0 - t), weight(f, t)], across }.holding(doc, p, span)
}
/// `p` on the segment `edge`, at the curve parameter of the segment's point nearest to it.
/// None when the segment is gone or `p` is not on it.
pub fn on_segment(doc: &Document, edge: &SegmentRef, p: Point) -> Option<Self> {
use vectorcraft_geom::kurbo::ParamCurveNearest;
let sp = doc.node(edge.node)?.path_data()?.subpaths.get(edge.subpath)?;
if edge.segment >= sp.segment_count() {
return None;
}
let (a, b) = edge.endpoints(doc)?;
let t = sp.segment(edge.segment).nearest(p, 1e-12).t;
Self::OnSegment { edge: edge.clone(), t }.holding(doc, p, a.distance(b))
}
/// Itself when it is [`Self::valid`] and puts the point at `p`, within a tolerance that grows
/// with `span`, the size of the art that holds it.
fn holding(self, doc: &Document, p: Point, span: f64) -> Option<Self> {
let at = self.position(doc)?;
(at.distance(p) <= 1e-6 * span.max(1.0)).then_some(self)
}
/// Whether its numbers can be used: a finite `t` from 0 to 1 on a segment; or two or three
/// weights that add up to 1, with `across` set only for two, each finite and at most
/// [`Self::MAX_WEIGHT`] across.
pub fn valid(&self) -> bool {
match self {
Self::OnSegment { t, .. } => (0.0..=1.0).contains(t),
Self::Anchors { anchors, across, .. } => {
(2..=3).contains(&anchors.len())
&& anchors.iter().all(|w| w.weight.is_finite() && w.weight.abs() <= Self::MAX_WEIGHT)
&& (anchors.iter().map(|w| w.weight).sum::<f64>() - 1.0).abs() <= 1e-9
&& across.is_finite()
&& across.abs() <= Self::MAX_WEIGHT
&& (*across == 0.0 || anchors.len() == 2)
}
}
}
/// Where the point is now. None when its segment or an anchor is gone or the point is not
/// [`Self::valid`].
pub fn position(&self, doc: &Document) -> Option<Point> {
use vectorcraft_geom::kurbo::ParamCurve;
if !self.valid() {
return None;
}
let at = match self {
Self::OnSegment { edge, t } => {
let sp = doc.node(edge.node)?.path_data()?.subpaths.get(edge.subpath)?;
if edge.segment >= sp.segment_count() {
return None;
}
sp.segment(edge.segment).eval(*t)
}
Self::Anchors { node, anchors, across } => {
let path = doc.node(*node)?.path_data()?;
let place = |w: &AnchorWeight| path.subpaths.get(w.subpath).and_then(|sp| sp.anchors.get(w.anchor)).map(|a| w.part.of(a).to_vec2());
let mut at = Vec2::ZERO;
for w in anchors {
at += place(w)? * w.weight;
}
if *across != 0.0 {
let u = place(anchors.get(1)?)? - place(anchors.first()?)?;
at += Vec2::new(-u.y, u.x) * *across;
}
at.to_point()
}
};
(at.x.is_finite() && at.y.is_finite()).then_some(at)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Dimension {
@@ -441,6 +708,36 @@ impl Dimension {
self.offset = offset;
}
}
/// A linear dimension whose art was reflected, its points now moved from `old`'s: its line goes
/// to the side of the points that `image` (where the reflection put `old`'s line) is on, as far
/// past the outermost point on that side as `old`'s line was. A line that lay between the two
/// points is left as it is.
pub fn mirror_line(&mut self, old: &Dimension, image: Point) {
if self.mode != DimensionMode::Linear || old.mode != DimensionMode::Linear {
return;
}
let gap = {
let n = old.normal();
let across = |p: Point| p.to_vec2().dot(n);
let line = across(old.a) + old.offset;
let (low, high) = (across(old.a).min(across(old.b)), across(old.a).max(across(old.b)));
if line >= high {
line - high
} else if line <= low {
low - line
} else {
return;
}
};
let n = self.normal();
let across = |p: Point| p.to_vec2().dot(n);
let (low, high) = (across(self.a).min(across(self.b)), across(self.a).max(across(self.b)));
let at = if 2.0 * across(image) >= low + high { high + gap } else { low - gap };
let offset = at - across(self.a);
if offset.is_finite() && offset.abs() <= 1e6 {
self.offset = offset;
}
}
/// Whether a linear dimension's line lies strictly between its two points along the normal.
fn line_between_points(&self) -> bool {
let n = self.normal();
@@ -525,10 +822,15 @@ impl Dimension {
}
pub fn resolve(&mut self, doc: &Document) -> Result<(), String> {
match &self.source {
Some(DimensionSource::Points { a, b, vertex }) => {
for (reference, point) in [(a, &mut self.a), (b, &mut self.b), (vertex, &mut self.vertex)] {
if let Some(r) = reference {
*point = r.position(doc).ok_or("Measured anchor no longer exists")?;
Some(DimensionSource::Points { a, b, vertex, free_a, free_b }) => {
for (reference, free, point) in [(a, free_a, &mut self.a), (b, free_b, &mut self.b), (vertex, &None, &mut self.vertex)] {
match (reference, free) {
// An end is an anchor or a free point, never both.
(Some(_), Some(_)) => return Err("Invalid measured point".into()),
(Some(r), None) => *point = r.position(doc).ok_or("Measured anchor no longer exists")?,
(None, Some(f)) if !f.valid() => return Err("Invalid measured point".into()),
(None, Some(f)) => *point = f.position(doc).ok_or("Measured anchor no longer exists")?,
(None, None) => {}
}
}
self.length = if self.mode == DimensionMode::Linear { self.measured() } else { self.a.distance(self.b) };
+4 -1
View File
@@ -373,7 +373,10 @@ impl Node {
NodeKind::SymbolInstance { symbol, xf } => {
let b = match symbols.and_then(|s| Some((s, s.art(symbol)?))) {
Some((s, art)) if !s.art_strokes() => xf.transform_rect_bbox(art.geometric),
Some((_, art)) if moves_only(*xf) => xf.transform_rect_bbox(art.visual),
// A symbol drawn at other than its natural size goes through its 9-slice guides.
Some((s, art)) if moves_only(*xf) && s.document().symbol_slice_map_of(symbol, art.geometric, *xf).is_none() => {
xf.transform_rect_bbox(art.visual)
}
Some((s, art)) => {
let placed = xf.transform_rect_bbox(art.geometric);
match s.placed_visual(symbol, *xf).or_else(|| s.sliced_visual(symbol, *xf)) {
+5 -3
View File
@@ -468,12 +468,14 @@ pub fn adjust(n: &Node, outer: Option<&ColorMap>, swatches: &SwatchKinds, images
}
/// [`adjust`] for export and Expand Appearance: recoloured embedded images become new images in
/// `d` (named by their content), and a symbol instance stands for its symbol's art.
pub fn adjust_in_document(d: &mut Document, n: &Node) -> Option<Node> {
/// `d` (named by their content), and a symbol instance stands for its symbol's art: `symbol_art`
/// when given, otherwise the symbol's art with the instance's own appearance changes.
pub fn adjust_in_document(d: &mut Document, n: &Node, symbol_art: Option<Node>) -> Option<Node> {
let art;
let src = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } if has_adjustment(n) => {
let symbol = d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n));
let symbol = symbol_art
.or_else(|| d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n)));
art = crate::reshape::as_art(Some(d), n, symbol.as_ref())?;
&art
}
+1 -1
View File
@@ -112,7 +112,7 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
}
// Colour adjustments: the object recoloured (inside it too), then baked as it is.
if has_adjustment(n)
&& let Some(m) = adjust_in_document(d, n)
&& let Some(m) = adjust_in_document(d, n, None)
{
return Some(bake_node(d, &m).unwrap_or(m));
}
+7 -2
View File
@@ -939,7 +939,10 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids));
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted))
})?;
s.remap_appearance_item(&ids, |a| (!idx.contains(&a)).then(|| a - idx.iter().filter(|i| **i < a).count()));
// The rows removed from the active row's object, which need not come first in `ids`.
let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied());
let gone = active.and_then(|a| ids.iter().position(|id| *id == a)).and_then(|k| per.get(k)).unwrap_or(&idx);
s.remap_appearance_item(&ids, |a| (!gone.contains(&a)).then(|| a - gone.iter().filter(|i| **i < a).count()));
ok()
}
@@ -1023,10 +1026,12 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
// Rows at or above each copy move up by one (by the block, for copies placed at `to`).
let m = idx.len();
let landed = active_at.or(first_at);
// The rows copied in the active row's object, which need not come first in `ids`.
let copied = active.and_then(|a| ids.iter().position(|id| *id == a)).and_then(|k| per.get(k)).unwrap_or(&idx);
s.remap_appearance_item(&ids, |a| {
Some(match to {
Some(_) => a + if landed.is_some_and(|at| at <= a) { m } else { 0 },
None => a + idx.iter().filter(|i| **i < a).count(),
None => a + copied.iter().filter(|i| **i < a).count(),
})
});
ok()
+17 -8
View File
@@ -391,9 +391,10 @@ fn expanded_bound(d: &Document, inst: &Node, own: bool) -> u64 {
}
/// Replace instances `ids` with their art ([`expanded_instance`], `own` as there). A hidden or
/// locked instance's art stays hidden or locked. Returns the art's ids. Fails before any edit when
/// the instances together would make more than [`MAX_EXPAND_NODES`] objects.
fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result<Vec<NodeId>> {
/// locked instance's art stays hidden or locked. Returns each replaced instance's id with its art's
/// id. Fails before any edit when the instances together would make more than
/// [`MAX_EXPAND_NODES`] objects.
fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result<Vec<(NodeId, NodeId)>> {
let total = ids.iter().filter_map(|id| d.node(*id)).fold(0u64, |sum, n| sum.saturating_add(expanded_bound(d, n, own)));
if total > MAX_EXPAND_NODES as u64 {
return Err(EngineError::Other("Too many pieces to make at once".into()));
@@ -408,7 +409,7 @@ fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result<Vec<NodeId
let aid = art.id;
d.remove(*id)?;
d.insert(par, idx, art)?;
out.push(aid);
out.push((*id, aid));
}
Ok(out)
}
@@ -495,12 +496,20 @@ pub(super) fn symbol_break_link(s: &mut Session, p: &Value) -> Result<Value> {
}
let iso = s.doc()?.isolation;
let out = s.edit("Break Link to Symbol", |d, sel| {
let out = break_links(d, &ids, true)?;
let fresh = d.new_art.get();
let made = break_links(d, &ids, true)?;
// Art that is hidden or locked, or in a hidden or locked group or layer, is left out of the
// selection.
// selection, as is art on a locked artboard unless the instance it replaces was new art.
let locks = d.locked_artboards();
sel.set(out.iter().copied().filter(|id| crate::cmd::select::is_selectable(d, iso, &locks, *id)));
Ok(out)
sel.set(made.iter().filter_map(|(inst, art)| {
let keep = if fresh.contains(inst) {
crate::cmd::select::is_selectable_on_any_artboard(d, iso, *art)
} else {
crate::cmd::select::is_selectable(d, iso, &locks, *art)
};
keep.then_some(*art)
}));
Ok(made.into_iter().map(|(_, art)| art).collect::<Vec<_>>())
})?;
Ok(json!({ "ids": ids_json(&out) }))
}
+28 -7
View File
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Dimension",
[],
None,
"{mode?: linear|angular|radius|diameter, a?: [x,y], b?: [x,y], vertex?: [x,y], source?: {kind: segment, edge: {node,subpath,segment}}|{kind: angle, first: edge, second: edge}|{kind: circle, node}|{kind: arc, edge}|{kind: points, a?, b?, vertex?: {node,subpath,anchor}}, orientation?: aligned|horizontal|vertical (linear only: the distance between the points, or only its horizontal or vertical part; default aligned), offset?: pt (the line's distance from the first point: along the left normal of a → b when aligned, downward when horizontal, leftward when vertical; negative goes the other way), options?: {units: in|mm|pt|pc|px|cm|m|ft|yd, precision: 0-6, scale: drawing:real, hideUnits, lineWeight, lineType: solid|dashed|dotted, lineColor: [r,g,b], arrowStyle: filled|open|tick|none, arrowScale: %, extensionWeight, extensionType, extensionColor, extensionOffset, extensionLength, hideExtensions, fontFamily, fontStyle, fontSize, textColor, textPosition: away|between, textAlignment: left|center|right, textOffset}} → {id,label}; linked measurements follow source edits",
"{mode?: linear|angular|radius|diameter, a?: [x,y], b?: [x,y], vertex?: [x,y], source?: {kind: segment, edge: {node,subpath,segment}}|{kind: angle, first: edge, second: edge}|{kind: circle, node}|{kind: arc, edge}|{kind: points, a?, b?, vertex?: {node,subpath,anchor}, freeA?, freeB?: a point that is not an anchor, {kind: onSegment, edge: {node,subpath,segment}, t: 0..1 (curve parameter)}|{kind: anchors, node, anchors: [{subpath,anchor,part?: point|in|out (the anchor, default, or its in or out handle),weight}] (2 or 3 points of one path whose weights add up to 1), across?: (2 anchors only: times the first-to-second anchor vector (x,y) turned to (-y,x), which points down when the vector points right)} (an end is an anchor or a free point, not both)}, orientation?: aligned|horizontal|vertical (linear only: the distance between the points, or only its horizontal or vertical part; default aligned), offset?: pt (the line's distance from the first point: along the left normal of a → b when aligned, downward when horizontal, leftward when vertical; negative goes the other way), options?: {units: in|mm|pt|pc|px|cm|m|ft|yd, precision: 0-6, scale: drawing:real, hideUnits, lineWeight, lineType: solid|dashed|dotted, lineColor: [r,g,b], arrowStyle: filled|open|tick|none, arrowScale: %, extensionWeight, extensionType, extensionColor, extensionOffset, extensionLength, hideExtensions, fontFamily, fontStyle, fontSize, textColor, textPosition: away|between, textAlignment: left|center|right, textOffset}} → {id,label}; linked measurements follow source edits",
has_doc,
create
),
@@ -231,9 +231,10 @@ fn apply_to_all(s: &mut Session, p: &Value) -> Result<Value> {
fn replace(doc: &mut Document, id: NodeId, dim: Dimension) -> Result<()> {
let old: Vec<NodeId> = doc.node(id).and_then(Node::children).map(|ch| ch.iter().map(|n| n.id).collect()).unwrap_or_default();
let children = artwork(doc, &dim, &old)?;
let name = format!("Dimension · {}", dim.label().map_err(error)?);
if let Some(n) = doc.node_mut(id) {
n.kind = NodeKind::Group { children, clip: false };
n.name = Some(format!("Dimension · {}", dim.label().map_err(error)?));
n.name = Some(name);
n.extras_mut().dimension = Some(Box::new(dim));
}
Ok(())
@@ -272,7 +273,8 @@ impl EndedLink {
/// The [`reached`] dimensions become plain groups, whose art does not redraw or follow what it
/// measured. A command that replaces art calls this before it changes anything, since replacing
/// the art a dimension measures would remove the dimension, and then [`restore_unchanged`].
/// the art a dimension measures would otherwise leave the dimension unlinked, and then
/// [`restore_unchanged`].
pub(crate) fn end_links(doc: &mut Document, roots: &[NodeId]) -> Vec<EndedLink> {
let mut ended = Vec::new();
for id in reached(doc, roots) {
@@ -315,18 +317,37 @@ pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<bool> {
let mut next = old.clone();
let moved = next.moved.take();
if next.source.as_ref().is_some_and(|s| s.topology_changed(before, doc)) || next.resolve(doc).is_err() {
// A deleted/degenerate source has no valid measurement. Remove its annotation in
// this same undo step instead of keeping a silently wrong number.
doc.remove(id)?;
// The art it measured was deleted, or rebuilt with other anchors, or no longer fits
// (an ellipse that was a circle): it stays where it was, no longer linked, in this
// same undo step, rather than attaching to a different edge.
let mut unlinked = old.clone();
unlinked.source = None;
unlinked.moved = None;
if let Some(v) = moved {
let mut shifted = unlinked.clone();
(shifted.a, shifted.b, shifted.vertex) = (shifted.a + v, shifted.b + v, shifted.vertex + v);
if shifted.validate().is_ok() {
unlinked = shifted;
}
}
if replace(doc, id, unlinked).is_err()
&& let Some(n) = doc.node_mut(id)
{
// Its recipe cannot be drawn again: its art stays as it is, as a plain group.
n.extras_mut().dimension = None;
}
changed = true;
continue;
}
next.keep_gap(&old);
if let Some(xf) = next.source.as_ref().and_then(|s| s.reflection(before, doc)) {
next.mirror_line(&old, xf * old.handle());
}
// Moved on its own (the art it measures stayed put): it keeps its new place.
if let Some(v) = moved
&& (next.a, next.b, next.vertex) == (old.a, old.b, old.vertex)
{
next.place(next.handle() + v);
next.place(old.handle() + v);
if next.resolve(doc).is_err() {
next = old.clone();
next.moved = None;
+8 -1
View File
@@ -814,8 +814,15 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br
let mut changed = false;
// Is `n` in `out` yet?
let mut listed_n = false;
// A symbol instance's fill tints its art before the adjustment, as the canvas draws it.
let symbol_art = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } if effects::has_adjustment(&n) => {
d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_render::instance_art(&s.art, &n))
}
_ => None,
};
if effects::has_adjustment(&n)
&& let Some(m) = effects::adjust_in_document(d, &n)
&& let Some(m) = effects::adjust_in_document(d, &n, symbol_art)
{
out.push((id, 0));
listed_n = true;
+1 -1
View File
@@ -242,7 +242,7 @@ fn expand_steps(s: &mut Session, p: &Value) -> Result<Value> {
let to = GradientTo::parse(p)?;
let from = s.doc()?.history.undo.len();
let mut e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
// Before anything expands: outlining the art a dimension measures would remove the dimension.
// Before anything expands: outlining the art a dimension measures would leave the dimension unlinked.
let before = s.doc()?.doc.clone();
let mut ended = vec![];
end_dimension_links(s, &mut ended)?;
File diff suppressed because one or more lines are too long
+22
View File
@@ -170,6 +170,28 @@ fn transparency_is_flattened_with_the_flattener_preset() {
assert!(e.contains("preset"), "{e}");
}
/// A picture cropped by a clipping path, in a clip group at 80%, over a square that only the
/// hidden part of the picture covers: the file prints the square, and its image is as large as
/// the crop, with the default preset (a rectangle of image) and with High Resolution (an image
/// clipped to its regions).
#[test]
fn flattening_cropped_art_keeps_the_art_under_its_hidden_part() {
use super::tests_pdfx::{Art, CROP, cropped, drawn, lost, shown_image_sizes};
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
for preset in ["medium", "high"] {
let (mut s, _) = cropped("rgb", 400.0, Art::Image { see_through: false });
s.execute("transparency.set", &json!({"opacity": 80})).unwrap();
let canvas = drawn(&s.doc().unwrap().doc);
let opts = json!({"artboards": [0], "flattenerPreset": preset, "previewFormat": "none", "thumbnails": false});
let r = s.execute("document.exportEps", &opts).unwrap();
// What the file prints, not the document it carries.
let back = vectorcraft_eps::import_with(&b64(&r), false).unwrap().document;
assert_eq!(lost(&canvas, &drawn(&back)), 0, "{preset}: art went missing");
let sizes = shown_image_sizes(&back);
assert!(!sizes.is_empty() && sizes.iter().all(|(w, h)| *w <= CROP[2] + 1.0 && *h <= CROP[3] + 1.0), "{preset}: the images show {sizes:?}");
}
}
#[test]
fn overprints_are_kept_or_discarded() {
let mut s = session(100.0, 100.0, 1);
+513
View File
@@ -186,3 +186,516 @@ fn pdfx_1a_flattening_keeps_the_inks_behind_an_image_in_a_cmyk_document() {
let ink = image_inks_at(&back, vectorcraft_geom::Point::new(88.0, 50.0)).expect("a CMYK image behind the see-through pixels");
assert!(ink.iter().zip(teal).all(|(a, b)| (a - b).abs() <= 1.5 / 255.0), "{ink:?} is not {teal:?}");
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
/// The art a clip group crops in [`cropped`].
#[derive(Clone, Copy)]
pub(super) enum Art {
/// A 240 × 160 image, with see-through pixels or without.
Image { see_through: bool },
/// A rectangle with a gradient fill.
Gradient,
/// A rectangle with a drop shadow.
Shadow,
}
/// Where [`cropped`] puts the cropped art, [x, y, width, height].
const FRAME: [f64; 4] = [40.0, 40.0, 240.0, 160.0];
/// The part of [`FRAME`] the clipping path of [`cropped`] shows.
pub(super) const CROP: [f64; 4] = [50.0, 50.0, 80.0, 60.0];
/// A `mode` document whose first artboard is `width` wide, with a dark square and, over it, `art`
/// filling [`FRAME`] in a clip group that shows [`CROP`] of it. The square lies inside the frame
/// and outside the crop: only the hidden part of the art covers it. → the session with the clip
/// group selected, and the group's id.
pub(super) fn cropped(mode: &str, width: f64, art: Art) -> (Session, u64) {
cropped_by(mode, width, art, false)
}
/// [`cropped`], with the art and its clipping path in a clipping layer above the square's layer
/// when `layer`. → the session and the clip group's or the layer's id.
fn cropped_by(mode: &str, width: f64, art: Art, layer: bool) -> (Session, u64) {
let [x, y, w, h] = FRAME;
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": width, "height": 300, "colorMode": mode}));
run(&mut s, "shape.rectangle", json!({"x": 180, "y": 90, "width": 60, "height": 60}));
run(&mut s, "paint.setFill", json!({"color": "#203040"}));
run(&mut s, "paint.setStroke", json!({"none": true}));
let layer = layer.then(|| run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap());
let art = match art {
Art::Image { see_through } => {
let mut png = vec![];
image::RgbaImage::from_fn(240, 160, |x, y| {
image::Rgba([200, (x % 256) as u8, (y % 256) as u8, if see_through && x < 60 { 128 } else { 255 }])
})
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let placed =
run(&mut s, "file.place", json!({"name": "photo.png", "dataBase64": vectorcraft_format::base64_encode(&png), "rect": FRAME}));
placed["ids"][0].clone()
}
Art::Gradient | Art::Shadow => {
let id = run(&mut s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].clone();
run(&mut s, "paint.setStroke", json!({"none": true}));
if matches!(art, Art::Gradient) {
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]);
run(&mut s, "paint.setFill", json!({"gradient": {"kind": "linear", "stops": stops}}));
} else {
run(&mut s, "paint.setFill", json!({"color": "#c04020"}));
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [id]}));
}
id
}
};
let [x, y, w, h] = CROP;
let clip = run(&mut s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].clone();
if let Some(layer) = layer {
run(&mut s, "layer.clippingMask.toggle", json!({"id": layer}));
run(&mut s, "select.set", json!({"ids": [art]}));
return (s, layer);
}
run(&mut s, "select.set", json!({"ids": [art, clip]}));
let group = run(&mut s, "object.clippingMask.make", json!({}))["id"].as_u64().unwrap();
(s, group)
}
/// `document.exportPdf` with `p`, read back.
fn read_back(s: &mut Session, p: &Value) -> vectorcraft_doc::Document {
let v = run(s, "document.exportPdf", p.clone());
vectorcraft_pdf::import(&vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap()
}
/// `doc`'s artboards drawn on white at 72 ppi.
pub(super) fn drawn(doc: &vectorcraft_doc::Document) -> vectorcraft_render::Rendered {
let pages = doc.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)).unwrap();
vectorcraft_render::Renderer::new().render_region(doc, pages, 1.0, true)
}
/// How many pixels show art in `reference` and are blank in `variant`.
pub(super) fn lost(reference: &vectorcraft_render::Rendered, variant: &vectorcraft_render::Rendered) -> usize {
assert_eq!((reference.width, reference.height), (variant.width, variant.height));
let (reference, variant) = (reference.pixels.as_chunks::<4>().0, variant.pixels.as_chunks::<4>().0);
reference.iter().zip(variant).filter(|(r, v)| r[..3].iter().any(|c| *c < 200) && v[..3].iter().all(|c| *c >= 250)).count()
}
/// How many pixels of `a` and `b` differ by more than 24 in a channel.
fn differ(a: &vectorcraft_render::Rendered, b: &vectorcraft_render::Rendered) -> usize {
assert_eq!((a.width, a.height), (b.width, b.height));
let (a, b) = (a.pixels.as_chunks::<4>().0, b.pixels.as_chunks::<4>().0);
a.iter().zip(b).filter(|(p, q)| p[..3].iter().zip(&q[..3]).any(|(x, y)| x.abs_diff(*y) > 24)).count()
}
/// The width and height of what each of `doc`'s images shows: its frame inside the clipping
/// paths around it.
pub(super) fn shown_image_sizes(doc: &vectorcraft_doc::Document) -> Vec<(f64, f64)> {
use vectorcraft_geom::Rect;
fn visit(n: &vectorcraft_doc::Node, clip: Option<Rect>, out: &mut Vec<(f64, f64)>) {
let within = |r: Rect| clip.map_or(r, |c| c.intersect(r));
if let vectorcraft_doc::NodeKind::Image(im) = &n.kind {
let frame = im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64));
let shown = within(frame);
out.push((shown.width(), shown.height()));
}
let clip = if n.clips() { n.visual_bounds().map(within) } else { clip };
for c in n.children().into_iter().flatten() {
visit(c, clip, out);
}
}
let mut out = vec![];
for l in &doc.layers {
visit(l, None, &mut out);
}
out
}
/// Checks that the PDF/X-1a, PDF/X-3 and PDF 1.3 files of `s` (with `extra` options) show all the
/// art the file with transparency shows, and that no image in them shows more than [`CROP`]. Adds
/// what is wrong to `wrong`.
fn check_flat_files(s: &mut Session, extra: &Value, what: &str, wrong: &mut Vec<String>) {
let reference = drawn(&read_back(s, extra));
for flat in [json!({"standard": "pdfX1a"}), json!({"standard": "pdfX3"}), json!({"compatibility": "1.3", "preserveEditing": false})] {
let mut p = flat.clone();
super::pdf::merge(&mut p, extra);
let back = read_back(s, &p);
let lost = lost(&reference, &drawn(&back));
if lost > 0 {
wrong.push(format!("{what}, {flat}: {lost} pixels of art went missing"));
}
let sizes = shown_image_sizes(&back);
// An image reaches a pixel past its regions, and a group rasterized whole a point past
// its visible bounds.
if sizes.is_empty() || sizes.iter().any(|(w, h)| *w > CROP[2] + 3.0 || *h > CROP[3] + 3.0) {
wrong.push(format!("{what}, {flat}: the images show {sizes:?}"));
}
}
}
/// Art cropped by a clipping path, in a PDF file without transparency: the part of the art the
/// clipping path hides is not rasterized, so art that lies under the hidden part alone still
/// shows. Each case is one way the cropped art comes to be flattened.
#[test]
fn flattening_cropped_art_for_pdfx_keeps_the_art_under_its_hidden_part() {
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let opaque = Art::Image { see_through: false };
type Change = fn(&mut Session, u64);
let cases: [(&str, Art, Change); 9] = [
("an image with see-through pixels", Art::Image { see_through: true }, |_, _| {}),
("a clip group with opacity", opaque, |s, _| {
run(s, "transparency.set", json!({"opacity": 80}));
}),
("a clip group with a blend mode", opaque, |s, _| {
run(s, "transparency.set", json!({"blendMode": "multiply"}));
}),
("a see-through object over the crop", opaque, |s, _| {
run(s, "shape.rectangle", json!({"x": 60, "y": 60, "width": 40, "height": 30}));
run(s, "paint.setFill", json!({"color": "#ffffff"}));
run(s, "transparency.set", json!({"opacity": 60}));
}),
("a gradient", Art::Gradient, |s, _| {
run(s, "transparency.set", json!({"opacity": 80}));
}),
("a raster effect", Art::Shadow, |_, _| {}),
("a clip group inside a clip group", Art::Image { see_through: true }, |s, group| {
let outer = run(s, "shape.rectangle", json!({"x": 45, "y": 45, "width": 85, "height": 65}))["id"].clone();
run(s, "select.set", json!({"ids": [group, outer]}));
run(s, "object.clippingMask.make", json!({}));
}),
("a clip group inside a group with opacity", opaque, |s, _| {
run(s, "object.group", json!({}));
run(s, "transparency.set", json!({"opacity": 80}));
}),
("an even-odd compound clipping path", opaque, |s, group| {
run(s, "transparency.set", json!({"opacity": 80}));
let doc = s.doc_mut().unwrap();
let mut d = (*doc.doc).clone();
let clip = d.node(vectorcraft_doc::NodeId(group)).unwrap().children().unwrap()[0].id;
let mut ring = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(50.0, 50.0, 130.0, 110.0));
ring.subpaths.extend(vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(70.0, 65.0, 110.0, 95.0)).subpaths);
if let vectorcraft_doc::NodeKind::Path { path, rule, .. } = &mut d.node_mut(clip).unwrap().kind {
(*path, *rule) = (ring, vectorcraft_geom::FillRule::EvenOdd);
}
doc.doc = d.into();
}),
];
let mut wrong = vec![];
for mode in ["cmyk", "rgb"] {
for (what, art, change) in cases {
let (mut s, group) = cropped(mode, 400.0, art);
change(&mut s, group);
check_flat_files(&mut s, &json!({}), &format!("{mode}: {what}"), &mut wrong);
}
}
assert!(wrong.is_empty(), "{wrong:#?}");
}
/// The same with the Medium and Low Resolution presets, and with a balance of 0, which rasterizes
/// the whole group of the cropped art over the group's visible bounds.
#[test]
fn flattening_cropped_art_for_pdfx_keeps_the_art_under_its_hidden_part_with_each_preset() {
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let mut wrong = vec![];
for extra in [json!({"flattenerPreset": "medium"}), json!({"flattenerPreset": "low"}), json!({"flattener": {"balance": 0}})] {
for (art, name) in [(Art::Image { see_through: true }, "image"), (Art::Gradient, "gradient")] {
let (mut s, _) = cropped("cmyk", 400.0, art);
run(&mut s, "transparency.set", json!({"opacity": 80}));
check_flat_files(&mut s, &extra, &format!("{name}, {extra}"), &mut wrong);
}
}
// A cropped rectangle with a solid fill, rasterized whole.
let (mut s, group) = cropped("cmyk", 400.0, Art::Gradient);
let art = s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(group)).unwrap().children().unwrap()[1].id.0;
run(&mut s, "paint.setFill", json!({"ids": [art], "color": "#c04020"}));
run(&mut s, "transparency.set", json!({"ids": [group], "opacity": 80}));
check_flat_files(&mut s, &json!({"flattener": {"balance": 0}}), "solid fill, balance 0", &mut wrong);
assert!(wrong.is_empty(), "{wrong:#?}");
}
/// The hidden part of cropped art on one artboard lies over the next artboard: that page keeps
/// its art.
#[test]
fn flattening_cropped_art_for_pdfx_keeps_the_next_page_under_its_hidden_part() {
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
// The crop is on the first artboard, and the square on the second.
let (mut s, _) = cropped("cmyk", 150.0, Art::Image { see_through: true });
run(&mut s, "artboard.new", json!({"x": 170, "y": 0, "width": 230, "height": 300}));
assert_eq!(read_back(&mut s, &json!({"standard": "pdfX1a"})).artboards.len(), 2);
let mut wrong = vec![];
check_flat_files(&mut s, &json!({}), "two artboards", &mut wrong);
assert!(wrong.is_empty(), "{wrong:#?}");
}
/// Art cropped by a clipping layer, over a square in the layer below: the square keeps showing,
/// also with a second square in that layer under both the crop and the hidden part, or with a
/// see-through rectangle over both in a layer above.
#[test]
fn flattening_art_of_a_clipping_layer_for_pdfx_keeps_the_art_under_its_hidden_part() {
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
type Change = fn(&mut Session, u64);
let cases: [(&str, Change); 3] = [
("alone", |_, _| {}),
("over a square under the crop's edge", |s, _| {
let below = s.doc().unwrap().doc.layers[0].id.0;
run(s, "layer.setCurrent", json!({"id": below}));
run(s, "shape.rectangle", json!({"x": 110, "y": 60, "width": 50, "height": 30}));
run(s, "paint.setFill", json!({"color": "#406020"}));
}),
("under a see-through rectangle over the crop's edge", |s, _| {
run(s, "layer.new", json!({}));
run(s, "shape.rectangle", json!({"x": 110, "y": 60, "width": 50, "height": 30}));
run(s, "paint.setFill", json!({"color": "#406020"}));
run(s, "transparency.set", json!({"opacity": 60}));
}),
];
let mut wrong = vec![];
for mode in ["cmyk", "rgb"] {
for art in [Art::Image { see_through: true }, Art::Gradient] {
for (what, change) in cases {
let (mut s, layer) = cropped_by(mode, 400.0, art, true);
run(&mut s, "transparency.set", json!({"opacity": 80}));
change(&mut s, layer);
check_flat_files(&mut s, &json!({}), &format!("{mode}: a clipping layer {what}"), &mut wrong);
}
}
}
assert!(wrong.is_empty(), "{wrong:#?}");
}
/// Documents in which no clip group hides art export the same bytes as they did when the
/// flattener rasterized hidden art too.
#[test]
fn flat_pdf_files_of_art_that_no_clip_group_hides_are_unchanged() {
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
use crate::cmd::flatten::with_clips_ignored as before;
// Written at one time, so two files differ only by what they draw.
let bytes = |s: &mut Session, p: &Value| super::pdf::encode_at("document.exportPdf", &s.doc().unwrap().doc, p, Some(1_700_000_000)).unwrap().0;
let files = [
json!({"standard": "pdfX1a"}),
json!({"standard": "pdfX3"}),
json!({"compatibility": "1.3", "preserveEditing": false}),
json!({"standard": "pdfX1a", "flattener": {"balance": 0}}),
json!({"standard": "pdfX1a", "flattenerPreset": "low"}),
];
// Transparency without a clip group.
let mut plain = session();
// A clip group at 80% whose clipping path is as large as its art, over the edge of a square.
let mut fitted = Session::new();
run(&mut fitted, "file.new", json!({"width": 400, "height": 300, "colorMode": "cmyk"}));
run(&mut fitted, "shape.rectangle", json!({"x": 180, "y": 90, "width": 160, "height": 160}));
run(&mut fitted, "paint.setFill", json!({"color": "#203040"}));
let mut png = vec![];
image::RgbaImage::from_fn(240, 160, |x, y| image::Rgba([200, x as u8, y as u8, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let photo =
run(&mut fitted, "file.place", json!({"name": "photo.png", "dataBase64": vectorcraft_format::base64_encode(&png), "rect": FRAME}))["ids"][0]
.clone();
let [x, y, w, h] = FRAME;
let clip = run(&mut fitted, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].clone();
run(&mut fitted, "select.set", json!({"ids": [photo, clip]}));
run(&mut fitted, "object.clippingMask.make", json!({}));
run(&mut fitted, "transparency.set", json!({"opacity": 80}));
// Cropped art that is not flattened: nothing on it is see-through.
let (mut opaque, _) = cropped("cmyk", 400.0, Art::Image { see_through: false });
for (what, s) in [("no clip group", &mut plain), ("a clipping path as large as its art", &mut fitted), ("opaque cropped art", &mut opaque)] {
for p in &files {
assert!(bytes(s, p) == before(|| bytes(s, p)), "{what}, {p}");
}
}
// Art that a clip group hides is what changes.
let (mut s, _) = cropped("cmyk", 400.0, Art::Image { see_through: true });
assert!(bytes(&mut s, &files[0]) != before(|| bytes(&mut s, &files[0])));
}
/// The options of the PDF files without transparency.
const FLAT_FILES: [&str; 3] = [r#"{"standard": "pdfX1a"}"#, r#"{"standard": "pdfX3"}"#, r#"{"compatibility": "1.3", "preserveEditing": false}"#];
/// [`cropped_by`]'s clipping layer of a gradient at 80%, with a rectangle in the layer below that
/// crosses the crop's right edge. → the session and the clipping layer's id.
fn clipping_layer_over_a_rectangle(mode: &str) -> (Session, u64) {
let (mut s, layer) = cropped_by(mode, 400.0, Art::Gradient, true);
run(&mut s, "transparency.set", json!({"opacity": 80}));
let below = s.doc().unwrap().doc.layers[0].id.0;
run(&mut s, "layer.setCurrent", json!({"id": below}));
run(&mut s, "shape.rectangle", json!({"x": 100, "y": 60, "width": 120, "height": 40}));
run(&mut s, "paint.setFill", json!({"color": "#406020"}));
(s, layer)
}
/// A clipping layer over a layer that doesn't print, and a clipping layer that doesn't print
/// over one that does: the files without transparency print what the file with transparency
/// prints, and nothing of the layer that doesn't print.
#[test]
fn flattening_for_pdfx_leaves_out_the_layers_that_do_not_print() {
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let mut wrong = vec![];
for mode in ["cmyk", "rgb"] {
for clipping_layer_prints in [true, false] {
let (mut s, layer) = clipping_layer_over_a_rectangle(mode);
let below = s.doc().unwrap().doc.layers[0].id.0;
let off = if clipping_layer_prints { below } else { layer };
run(&mut s, "layer.setProps", json!({"id": off, "printable": false}));
let reference = drawn(&read_back(&mut s, &json!({})));
for flat in FLAT_FILES {
let back = drawn(&read_back(&mut s, &serde_json::from_str(flat).unwrap()));
let (lost, gained) = (lost(&reference, &back), lost(&back, &reference));
if lost + gained > 0 {
wrong.push(format!("{mode}, clipping layer prints: {clipping_layer_prints}, {flat}: {lost} pixels lost, {gained} gained"));
}
}
}
}
assert!(wrong.is_empty(), "{wrong:#?}");
}
/// A clipping layer whose clipping path has a fill: the files without transparency show the art
/// over the fill, not the fill over the art.
#[test]
fn flattening_for_pdfx_keeps_the_art_of_a_clipping_layer_over_its_clipping_path_fill() {
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let mut wrong = vec![];
for mode in ["cmyk", "rgb"] {
let (mut s, layer) = clipping_layer_over_a_rectangle(mode);
let clip = s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(layer)).unwrap().children().unwrap()[0].id.0;
run(&mut s, "paint.setFill", json!({"ids": [clip], "color": "#ffdd00"}));
let reference = drawn(&read_back(&mut s, &json!({})));
for flat in FLAT_FILES {
let back = drawn(&read_back(&mut s, &serde_json::from_str(flat).unwrap()));
// PDF/X-1a converts an RGB document's colors to CMYK.
let converted = mode == "rgb" && flat.contains("pdfX1a");
let (lost, differ) = (lost(&reference, &back), if converted { 0 } else { differ(&reference, &back) });
if lost > 0 || differ > 100 {
wrong.push(format!("{mode}, {flat}: {lost} pixels of art lost, {differ} pixels differ"));
}
}
}
assert!(wrong.is_empty(), "{wrong:#?}");
}
/// Transparent art in a clipping layer whose clipping path is area type holding only a space,
/// which hides it: the files without transparency are written, without the art.
#[test]
fn flattening_for_pdfx_removes_transparent_art_that_a_clipping_layer_hides_entirely() {
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let [x, y, w, h] = FRAME;
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 400, "height": 300, "colorMode": "cmyk"}));
let layer = run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap();
run(&mut s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}));
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]);
run(&mut s, "paint.setFill", json!({"gradient": {"kind": "linear", "stops": stops}}));
run(&mut s, "transparency.set", json!({"opacity": 80}));
run(&mut s, "text.create", json!({"x": 50, "y": 50, "text": " ", "area": {"width": 80, "height": 60}}));
run(&mut s, "layer.clippingMask.toggle", json!({"id": layer}));
let reference = drawn(&read_back(&mut s, &json!({})));
for flat in FLAT_FILES {
let p: Value = serde_json::from_str(flat).unwrap();
let back = match s.execute("document.exportPdf", &p) {
Ok(v) => vectorcraft_pdf::import(&vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap(),
Err(e) => panic!("{flat}: {e}"),
};
assert_eq!(lost(&drawn(&back), &reference), 0, "{flat}: the hidden art prints");
}
}
/// Object › Flatten Transparency of art in a clipping layer and art in the layer below that
/// reaches past the clipping path: when the layer below doesn't print, the clipping layer has
/// opacity, or the clipping path has a fill (see-through too, flattened keeping alpha, in a layer
/// with opacity or Multiply mode, or
/// over a layer with opacity, overlapping art of a layer with opacity or art an opacity mask
/// hides), the group stays in the clipping layer, the art below stays in its layer, and the page
/// looks the same.
#[test]
fn flattening_keeps_the_group_in_the_clipping_layer_when_the_layer_below_differs() {
let mut wrong = vec![];
let cases = [
"the layer below doesn't print",
"the clipping path has a fill",
"the clipping path has a see-through fill, keeping alpha",
"the clipping path has a see-through fill, in a layer with opacity",
"the clipping path has a see-through fill, in a layer in Multiply mode",
"the clipping path has a see-through fill, over a layer with opacity",
"the clipping path has a see-through fill, over art that an opacity mask hides",
"the clipping path has a see-through fill, over overlapping art of a layer at 50%",
"the clipping path has no fill, in a layer with opacity",
];
for case in cases {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 400, "height": 300, "colorMode": "rgb"}));
let below = s.doc().unwrap().doc.layers[0].id.0;
let orange = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 200, "height": 150}))["id"].clone();
run(&mut s, "paint.setFill", json!({"color": "#ff8000"}));
let upper = run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap();
let blue = run(&mut s, "shape.rectangle", json!({"x": 100, "y": 80, "width": 200, "height": 150}))["id"].clone();
run(&mut s, "paint.setFill", json!({"color": "#2040c0"}));
run(&mut s, "transparency.set", json!({"opacity": 50}));
let clip = run(&mut s, "shape.rectangle", json!({"x": 60, "y": 40, "width": 300, "height": 220}))["id"].clone();
run(&mut s, "layer.clippingMask.toggle", json!({"id": upper}));
if case.starts_with("the layer below") {
run(&mut s, "layer.setProps", json!({"id": below, "printable": false}));
} else if !case.contains("no fill") {
run(&mut s, "paint.setFill", json!({"ids": [clip], "color": "#88ffcc"}));
}
let keeping_alpha = case.ends_with("keeping alpha");
if case.contains("see-through fill") {
run(&mut s, "transparency.set", json!({"ids": [clip], "opacity": 50}));
}
if case.ends_with("in a layer with opacity") {
run(&mut s, "transparency.set", json!({"ids": [upper], "opacity": 80}));
}
if case.ends_with("over a layer with opacity") {
run(&mut s, "transparency.set", json!({"ids": [below], "opacity": 50}));
}
if case.ends_with("an opacity mask hides") {
run(&mut s, "layer.setCurrent", json!({"id": below}));
run(&mut s, "select.set", json!({"ids": [orange]}));
let group = run(&mut s, "object.group", json!({}))["id"].clone();
let mask = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 200, "height": 150}))["id"].clone();
run(&mut s, "paint.setFill", json!({"color": "#000000"}));
run(&mut s, "select.set", json!({"ids": [group, mask]}));
run(&mut s, "transparency.makeOpacityMask", json!({}));
}
if case.ends_with("Multiply mode") {
run(&mut s, "transparency.set", json!({"ids": [upper], "blendMode": "multiply"}));
}
let mut art = vec![orange.clone()];
if case.ends_with("a layer at 50%") {
run(&mut s, "layer.setCurrent", json!({"id": below}));
art.push(run(&mut s, "shape.rectangle", json!({"x": 80, "y": 60, "width": 120, "height": 120}))["id"].clone());
run(&mut s, "paint.setFill", json!({"color": "#20a040"}));
run(&mut s, "transparency.set", json!({"ids": [below], "opacity": 50}));
}
art.push(blue.clone());
// The page as it prints.
let printed = |d: &vectorcraft_doc::Document| {
let mut d = d.clone();
if case.starts_with("the layer below") {
let mut layer = (*d.layers[0]).clone();
layer.visible = false;
d.layers[0] = layer.into();
}
drawn(&d)
};
let before = printed(&s.doc().unwrap().doc);
run(&mut s, "select.set", json!({"ids": art}));
run(&mut s, "object.flattenTransparency", json!({"preserveAlpha": keeping_alpha}));
let d = &s.doc().unwrap().doc;
let layer_of = |id: &Value| id.as_u64().and_then(|id| d.ancestry(vectorcraft_doc::NodeId(id))).map(|a| a[0].0);
if layer_of(&orange) != Some(below) {
wrong.push(format!("{case}: the art below is in layer {:?}", layer_of(&orange)));
}
let upper_art = d.node(vectorcraft_doc::NodeId(upper)).and_then(|l| l.children()).map_or(0, Vec::len);
if d.node(vectorcraft_doc::NodeId(blue.as_u64().unwrap())).is_some() || upper_art != 2 {
wrong.push(format!("{case}: the clipping layer holds {upper_art} objects"));
}
let after = printed(d);
// Flat regions differ from see-through art along their antialiased edges.
let (lost, differ) = (lost(&before, &after), differ(&before, &after));
if lost > 0 || differ > 1200 {
wrong.push(format!("{case}: {lost} pixels of art lost, {differ} pixels differ"));
}
}
assert!(wrong.is_empty(), "{wrong:#?}");
}
+2 -2
View File
@@ -49,9 +49,9 @@ mod presets;
mod report;
pub(crate) use analysis::{outlined_text, see_through_images};
#[cfg(test)]
pub(crate) use apply::with_every_region;
pub(crate) use apply::{expand_transparent_tile_images, flatten_document, flatten_expanded, has_transparent_tile_images};
#[cfg(test)]
pub(crate) use apply::{with_clips_ignored, with_every_region};
pub use commands::specs;
pub use options::FlattenOptions;
pub use presets::{FlattenerPreset, PRESET_EXTS, PRESET_FORMAT};
+261 -11
View File
@@ -1,4 +1,4 @@
use super::analysis::{Scratch, outlined_text, overlapping, reach};
use super::analysis::{Scratch, has_effects, masked, outlined_text, overlapping, reach};
use super::compose::{Builder, CLEAR, Composer, Elem, Fill, Kind, Px, WHITE};
use super::options::FlattenOptions;
use super::{
@@ -107,27 +107,83 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render:
let art: Vec<Node> = baked.layers[0].children().into_iter().flatten().map(|a| (**a).clone()).collect();
let mut sc = Scratch { brushes: vectorcraft_brush::library(&baked), doc: baked.clone() };
let plain: Vec<Node> = art.iter().map(|n| sc.plain(n)).collect();
let reaches: Vec<Option<Rect>> = plain.iter().map(reach).collect();
// Each object as its clip groups and clipping layers show it.
let clipped: Vec<Node> = plain.iter().zip(roots).map(|(n, id)| clipped_by_ancestors(src, *id, n, None)).collect();
let reaches: Vec<Option<Rect>> = plain.iter().zip(&clipped).map(|(n, c)| reach(n).zip(reach(c)).map(|(a, b)| a.intersect(b))).collect();
let see_through: Vec<bool> = plain.iter().map(|n| o.shows_transparency(&baked, n) || o.draws_see_through_image(n)).collect();
let cmyk = src.color_mode == ColorMode::Cmyk;
let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() };
// Baking above stopped early when cancelled; each group is a place to stop (a plan changes nothing).
vectorcraft_doc::cancel::check()?;
for g in overlapping(&reaches)? {
let mut queue = overlapping(&reaches)?;
queue.reverse();
while let Some(g) = queue.pop() {
vectorcraft_doc::cancel::check()?;
// A group that stays inside the clip groups and clipping layers of its topmost object
// replaces only the objects inside them, over the others, which are flattened on their own.
let (g, under) = match inside_and_under(src, roots, &g) {
Some((inside, under)) => {
queue.push(under.clone());
(inside, under)
}
None => (g, vec![]),
};
// Composited over white, the group covers what it is drawn over, so it is composited over
// the fills of the clipping paths around it and the objects under it in the same kind of
// layer. Composited over nothing, it keeps its alpha and they show through.
let backdrop = !o.preserve_alpha;
let state = g.last().map(|&i| layer_state(src, roots[i]));
let top = g.last().map(|&i| roots[i]);
// A blend mode of a layer or group around the group alone would blend the art under it
// twice.
let blends = |i: usize| top.is_some_and(|t| own_layers(src, t, Some(roots[i])).iter().any(|a| a.blend != BlendMode::Normal));
// Art under an opacity mask, effects or a knockout group is left out: the group can't draw it
// as those do.
let drawn_plainly = |i: usize| {
own_layers(src, roots[i], top).iter().all(|a| !masked(a) && !has_effects(a) && !matches!(a.knockout, vectorcraft_doc::Knockout::On))
};
let under_shown = under.iter().copied().filter(|&i| backdrop && Some(layer_state(src, roots[i])) == state && !blends(i) && drawn_plainly(i));
let composited: Vec<usize> = under_shown.chain(g.iter().copied()).collect();
let below = composited.len() - g.len();
let extent = composited.iter().filter_map(|&i| reaches[i]).reduce(|a, b| a.union(b)).unwrap_or_default();
let shown = |nodes: &[Node]| -> Vec<Node> {
let mut seen = vec![];
let (mut under, mut out) = (vec![], vec![]);
for (k, &i) in composited.iter().enumerate() {
let n = clipped_by_ancestors(src, roots[i], &nodes[i], backdrop.then_some((&mut seen, extent)));
if k < below {
let groups = own_layers(src, roots[i], top).into_iter().filter(|a| !draws_as_is(a)).collect();
under.push((groups, n));
} else {
out.push(n);
}
}
let mut all = in_groups(under, 0);
all.extend(out);
all
};
let ids: Vec<NodeId> = g.iter().map(|&i| roots[i]).collect();
let group = g.iter().any(|&i| see_through[i]).then(|| {
let plain: Vec<&Node> = g.iter().map(|&i| &plain[i]).collect();
flatten_group(&plain, o, cmyk)
let plain = shown(&plain);
flatten_group(&plain.iter().collect::<Vec<_>>(), o, cmyk)
});
let Some((regions, area)) = group.flatten() else {
out.kept.extend(ids);
// Transparent art that its clip groups or clipping layers hide entirely draws nothing:
// it is removed.
let hides = |i: usize| see_through[i] && reach(&plain[i]).is_some() && reach(&clipped[i]).is_none();
let (hidden, left): (Vec<usize>, Vec<usize>) = g.iter().partition(|&&i| hides(i));
if !hidden.is_empty() {
let roots = hidden.iter().map(|&i| roots[i]).collect();
out.flat.push(Flat { roots, regions: vec![], area: None, raster: None, lines: None });
}
out.kept.extend(left.iter().map(|&i| roots[i]));
continue;
};
let raster = match area.as_ref().filter(|_| render) {
Some(a) => {
let art: Vec<Node> = g.iter().map(|&i| art[i].clone()).collect();
Some(render_raster(&baked, &art, a.rect, a.ppi, a.clip.clone(), o)?)
// Drawn as planned: the image can land outside the clip groups and clipping layers
// around its art.
Some(render_raster(&baked, &shown(&art), a.rect, a.ppi, a.clip.clone(), o)?)
}
None => None,
};
@@ -140,6 +196,134 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render:
Ok(out)
}
/// `plain` (object `id` of `src` as plain art) inside a clip group for each clip group and
/// clipping layer around `id`, innermost first, each clipped by that group's or layer's clipping
/// path. `plain` itself when nothing around it clips. With `fills` (clipping paths, and an area),
/// the fill of each clipping path not in them yet goes behind the art, inside the area, and the
/// path is added to them.
fn clipped_by_ancestors(src: &Document, id: NodeId, plain: &Node, mut fills: Option<(&mut Vec<NodeId>, Rect)>) -> Node {
let mut out = plain.clone();
if !clips_count() {
return out;
}
let ancestry = src.ancestry(id).unwrap_or_default();
for pair in ancestry.windows(2).rev() {
let Some(above) = src.node(pair[0]).filter(|n| n.clips()) else { continue };
let Some(clip) = above.children().and_then(|c| c.first()) else { continue };
// The clipping path itself is not clipped art.
if clip.id == pair[1] {
continue;
}
// Its outline, unpainted: the group or layer around the object still paints the path.
// Nothing to clip by hides the clipped art, as in the compositor.
let Some((outline, rule)) = effects::clip_outline(clip) else {
out.visible = false;
return out;
};
let mut path = Node::path(clip.id, PathData::from_bezpath(&outline), Appearance::default());
if let NodeKind::Path { rule: r, .. } = &mut path.kind {
*r = rule;
}
let mut children = vec![Arc::new(path)];
// The renderer paints the fill behind the clipped art.
if let Some((seen, r)) = fills.as_mut()
&& !seen.contains(&clip.id)
&& let Some(fill) = clip.clip_paint().fill
{
let r = *r;
seen.push(clip.id);
let mut at = Node::group(above.id, vec![Arc::new(Node::path(clip.id, shapes::rectangle(r), Appearance::default())), Arc::new(fill)]);
at.set_clips(true);
children.push(Arc::new(at));
}
children.push(Arc::new(out));
let mut g = Node::group(above.id, children);
g.set_clips(true);
out = g;
}
out
}
/// Whether layer or group `n` draws what it holds as it is: opaque, in Normal mode, not isolated.
fn draws_as_is(n: &Node) -> bool {
n.opacity >= 1.0 && n.blend == BlendMode::Normal && !n.isolate
}
/// `items` (in paint order, each an object and the layers and groups around it, outermost first,
/// that don't draw it as it is) with the objects of each such layer or group in one group with its
/// opacity, blend mode and isolation, from the `depth`th around them on: they then composite
/// together as that layer or group draws them.
fn in_groups(items: Vec<(Vec<&Node>, Node)>, depth: usize) -> Vec<Node> {
let mut out = vec![];
let mut items = items.into_iter().peekable();
while let Some((around, n)) = items.next() {
let Some(a) = around.get(depth).copied() else {
out.push(n);
continue;
};
let mut run = vec![(around, n)];
while let Some(next) = items.next_if(|(around, _)| around.get(depth).is_some_and(|b| b.id == a.id)) {
run.push(next);
}
let mut g = Node::group(a.id, in_groups(run, depth + 1).into_iter().map(Arc::new).collect());
(g.opacity, g.blend, g.isolate) = (a.opacity, a.blend, a.isolate);
out.push(g);
}
out
}
/// The layers and groups around object `id` of `src` that are not also around `other`, outermost
/// first.
fn own_layers(src: &Document, id: NodeId, other: Option<NodeId>) -> Vec<&Node> {
let mine = src.ancestry(id).unwrap_or_default();
let theirs = other.and_then(|t| src.ancestry(t)).unwrap_or_default();
let shared = mine.iter().zip(&theirs).take_while(|(a, b)| a == b).count();
let own = mine.get(shared..mine.len().saturating_sub(1)).unwrap_or_default();
own.iter().filter_map(|a| src.node(*a)).collect()
}
/// The clip groups and clipping layers around object `id` of `d`, innermost first.
fn clip_ancestors(d: &Document, id: NodeId) -> Vec<NodeId> {
let mut a = d.ancestry(id).unwrap_or_default();
a.pop();
a.into_iter().rev().filter(|a| d.node(*a).is_some_and(Node::clips)).collect()
}
/// Whether the layers around object `id` of `d` all print, all show, and whether one is a
/// template.
fn layer_state(d: &Document, id: NodeId) -> (bool, bool, bool) {
let mut a = d.ancestry(id).unwrap_or_default();
a.pop();
a.iter().filter_map(|a| d.node(*a)).fold((true, true, false), |(print, show, template), n| match &n.kind {
NodeKind::Layer { printable, template: t, .. } => (print && *printable, show && n.visible, template || *t),
_ => (print, show, template),
})
}
/// For group `g` (indices of `roots` of `src`, in paint order) whose flattened group can't leave
/// the clip groups and clipping layers of its topmost object ([`place_of`]): the objects inside
/// them and the objects under them. `None` when the group can leave them, or every object is
/// inside them.
fn inside_and_under(src: &Document, roots: &[NodeId], g: &[usize]) -> Option<(Vec<usize>, Vec<usize>)> {
if !clips_count() || g.len() < 2 {
return None;
}
let ids: Vec<NodeId> = g.iter().map(|&i| roots[i]).collect();
let top = clip_ancestors(src, *ids.last()?);
if top.is_empty() {
return None;
}
let inside = |id: NodeId| {
let mine = clip_ancestors(src, id);
top.iter().all(|c| mine.contains(c))
};
if !inside(place_of(src, &ids)?) {
return None;
}
let (inside, under): (Vec<usize>, Vec<usize>) = g.iter().partition(|&&i| inside(roots[i]));
(!under.is_empty()).then_some((inside, under))
}
/// The regions and the image area replacing one group of overlapping objects (`plain`: the art as
/// composited; `cmyk`: in a CMYK document); `None` when it paints nothing.
fn flatten_group(plain: &[&Node], o: &FlattenOptions, cmyk: bool) -> Option<(Regions, Option<Area>)> {
@@ -163,8 +347,10 @@ fn flatten_group(plain: &[&Node], o: &FlattenOptions, cmyk: bool) -> Option<(Reg
let mut complex: Vec<usize> = vec![];
let mut gradients_only = !all;
for (k, r) in regions.iter().enumerate() {
if all || r.sources.iter().any(|&i| b.kinds[i] != Kind::Vector) {
gradients_only &= r.sources.iter().all(|&i| b.kinds[i] == Kind::Gradient);
// Art outside the clipping path of its clip group draws nothing there.
let shown = |i: &&usize| b.kinds[**i] == Kind::Vector || !(clips_count() && b.hidden(**i, &r.sources));
if all || r.sources.iter().filter(shown).any(|&i| b.kinds[i] != Kind::Vector) {
gradients_only &= r.sources.iter().filter(shown).all(|&i| b.kinds[i] == Kind::Gradient);
complex.push(k);
} else if let Some(fill) = region_fill(&elems, &r.sources, o, cmyk) {
vector.push((k, fill));
@@ -227,6 +413,35 @@ pub(crate) fn with_every_region<T>(f: impl FnOnce() -> T) -> T {
out
}
#[cfg(test)]
thread_local! {
/// Groups flattened on this thread rasterize art where its clip group or clipping layer hides
/// it too ([`with_clips_ignored`]).
static CLIPS_IGNORED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Whether a region outside the clipping path of a clip group or clipping layer leaves out the
/// art it hides there.
#[cfg(test)]
fn clips_count() -> bool {
!CLIPS_IGNORED.with(std::cell::Cell::get)
}
#[cfg(not(test))]
fn clips_count() -> bool {
true
}
/// `f` with every group it flattens rasterizing art where its clip group or clipping layer hides
/// it too.
#[cfg(test)]
pub(crate) fn with_clips_ignored<T>(f: impl FnOnce() -> T) -> T {
CLIPS_IGNORED.with(|e| e.set(true));
let out = f();
CLIPS_IGNORED.with(|e| e.set(false));
out
}
/// The flat colour of a region covered by `sources` (CMYK in a `cmyk` document); `None` where
/// nothing paints it.
fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool) -> Option<RegionFill> {
@@ -424,7 +639,18 @@ pub(super) fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan
let one_pass = separate(d, &plan.flat.iter().flat_map(|f| f.roots.iter().copied()).collect::<Vec<_>>());
for f in plan.flat {
// A group always has objects.
let Some(&top) = f.roots.last() else { continue };
let Some(top) = place_of(d, &f.roots) else { continue };
// Art hidden entirely is removed.
if f.regions.is_empty() && f.area.is_none() {
if one_pass {
flat.extend(f.roots.iter().map(|r| (*r, vec![])));
} else {
for r in &f.roots {
d.remove(*r)?;
}
}
continue;
}
let mut children = vec![];
// The image goes under the regions, reaching a pixel past its own: their edges then fall on
// matching pixels instead of on what is behind the group.
@@ -494,6 +720,30 @@ pub(super) fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan
Ok((ids, vector, rasterized))
}
/// The object of `roots` (in paint order) whose place their flattened group takes: the topmost one
/// that no clip group or clipping layer clips unless it clips every object of `roots`, else the
/// topmost one. The group stays with the topmost object rather than move to a layer that prints,
/// shows or is a template where the topmost object's layer differs, out from a clip group or
/// clipping layer whose clipping path has a fill, which would then cover it, or out from a layer
/// or group with opacity, a blend mode or isolation.
fn place_of(d: &Document, roots: &[NodeId]) -> Option<NodeId> {
let top = roots.last().copied()?;
if !clips_count() || roots.len() < 2 {
return Some(top);
}
let fills = |clip: &NodeId| d.node(*clip).and_then(|n| n.children()?.first().map(|c| c.clip_paint().fill.is_some())).unwrap_or(false);
let all: Vec<Vec<NodeId>> = roots.iter().map(|r| clip_ancestors(d, *r)).collect();
let above = all.last().map_or(&[][..], Vec::as_slice);
let shared = |mine: &Vec<NodeId>| mine.iter().all(|c| all.iter().all(|theirs| theirs.contains(c)));
let leaves_fill = |mine: &Vec<NodeId>| above.iter().any(|c| !mine.contains(c) && fills(c));
// Out of a layer or group with opacity, a blend mode or isolation, the group would lose it.
let leaves_compositing = |r: NodeId| own_layers(d, top, Some(r)).iter().any(|a| !draws_as_is(a));
let state = layer_state(d, top);
let place =
roots.iter().zip(&all).rev().find(|(r, mine)| shared(mine) && !leaves_fill(mine) && layer_state(d, **r) == state && !leaves_compositing(**r));
Some(place.map_or(top, |(r, _)| *r))
}
/// An image of `r` (in a clip group when it is clipped).
fn raster_node(d: &mut Document, r: Raster) -> Node {
let key = unique_key(d, "flattened");
+1 -1
View File
@@ -10,7 +10,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Flatten Transparency…",
["Object"],
None,
"{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay; a frame of threaded text that is outlined or flattened leaves its thread with the text it shows → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
"{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach (only where a clip group's or clipping layer's clipping path shows them, over its fill; transparent objects such a path hides entirely are removed); objects without transparency stay; a frame of threaded text that is outlined or flattened leaves its thread with the text it shows → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
has_doc,
flatten
),
+25 -1
View File
@@ -53,6 +53,11 @@ enum Body {
pub(super) struct Builder {
pub(super) shapes: Vec<po::Shape>,
pub(super) kinds: Vec<Kind>,
/// Per shape, the clipping path of the innermost clip group it is in (a clipping path's own
/// entry is the clip group around its group).
pub(super) clip_of: Vec<Option<usize>>,
/// The clipping path of the clip group being built.
pub(super) clip: Option<usize>,
/// Building a CMYK document's art.
pub(super) cmyk: bool,
}
@@ -72,9 +77,24 @@ impl Builder {
path.bounds()?;
self.shapes.push(po::Shape::new(path, rule, self.shapes.len() as u64));
self.kinds.push(kind);
self.clip_of.push(self.clip);
Some(self.shapes.len() - 1)
}
/// Whether a clip group hides shape `shape` in the region `sources` cover (ascending): the
/// region lies outside one of the clipping paths around the shape.
pub(super) fn hidden(&self, shape: usize, sources: &[usize]) -> bool {
let around = |shape: usize| self.clip_of.get(shape).copied().flatten();
let mut clip = around(shape);
while let Some(c) = clip {
if sources.binary_search(&c).is_err() {
return true;
}
clip = around(c);
}
false
}
pub(super) fn elem(&mut self, n: &Node) -> Option<Elem> {
if !n.visible {
return None;
@@ -93,7 +113,11 @@ impl Builder {
} else {
(None, &children[..])
};
Body::Group { clip, children: rest.iter().filter_map(|c| self.elem(c)).collect() }
let outer = self.clip;
self.clip = clip.or(outer);
let children = rest.iter().filter_map(|c| self.elem(c)).collect();
self.clip = outer;
Body::Group { clip, children }
}
NodeKind::Path { guide: true, .. } => return None,
NodeKind::Path { .. } | NodeKind::Compound { .. } => {
File diff suppressed because one or more lines are too long
+28 -6
View File
@@ -131,11 +131,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Reshape",
[],
None,
"{id, x, y, dx, dy, tol=3, focal?: [[subpath, anchor]…], radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy). The grabbed anchor and the anchors in focal are focal points and move by the whole drag. Anchors that are not direct-selected stay in place (a path selected whole, or not selected, has every anchor selected), and so do an open path's two ends unless they are focal points. A selected anchor between two focal points moves whole; one between a focal point and an anchor that stays takes a share of the drag that falls smoothly from 1 at the focal point to 0 at the anchor that stays, by its distance along the path's control polygon. A closed subpath with every anchor selected moves whole; a subpath with no focal point does not move. With radius, only the grabbed point's subpath changes: anchors follow with a smooth falloff over radius, measured along the path (0 or more), whatever the selection; focal and radius cannot be used together",
"{id, x, y, dx, dy, tol=3, focal?: [[subpath, anchor]…], radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy). The grabbed anchor and the anchors in focal are focal points and move by the whole drag. Anchors that are not direct-selected stay in place (a path selected whole, or not selected, has every anchor selected), and so do an open path's two ends unless they are focal points. A selected anchor between two focal points moves whole; one between a focal point and an anchor that stays takes a share of the drag that falls smoothly from 1 at the focal point to 0 at the anchor that stays, by its distance along the path's control polygon. A closed subpath with every anchor selected moves whole; a subpath with no focal point does not move. With radius, only the grabbed point's subpath changes: anchors follow with a smooth falloff over radius, measured along the path (0 or more), whatever the selection; focal and radius cannot be used together. Without focal (or with focal null) and without radius, the earlier rule applies: the selection does not matter, an open path stretches from the grabbed point to its two ends, and on a closed path the anchors follow with the falloff over half the diagonal of the subpath's bounding box; the Reshape tool always sends focal",
has_doc,
reshape
),
cmd!("path.insertAnchor", "Add Anchor Point", [], None, "{id, subpath?, segment, t: 0..1}", has_doc, insert_anchor),
cmd!(
"path.insertAnchor",
"Add Anchor Point",
[],
None,
"{id, subpath?, segment, t: 0..1} add an anchor at t on the segment → {anchor}; the direct-selected anchors and selected segments keep the same points (a selected segment it splits stays selected as its two halves), and a path that isn't selected becomes the selection with the new anchor direct-selected",
has_doc,
insert_anchor
),
cmd!(
"path.cutAtAnchors",
"Cut Path at Selected Anchor Points",
@@ -1573,6 +1581,11 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
if radius.is_some() && !focal.is_empty() {
return Err(bad("path.reshape", "`focal` and `radius` cannot be used together"));
}
// A step that names neither `focal` nor `radius` (as the Reshape tool recorded one before it
// had focal points) keeps that earlier rule: the selection does not matter, an open path
// stretches from the grabbed point to its two ends, and a closed one follows with a falloff
// over the default radius.
let earlier = radius.is_none() && matches!(p.get("focal"), None | Some(Value::Null));
{
let st = s.doc()?;
let node = st.doc.node(id).ok_or(EngineError::NoNode(id))?;
@@ -1605,11 +1618,12 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
added
}
};
let Some(r) = radius else {
let earlier_closed = earlier && sp.closed;
if radius.is_none() && !earlier_closed {
focal.insert((si, grabbed));
// Anchors that are not direct-selected stay; a path selected whole, or not selected,
// has every anchor selected.
let selected = sel.anchors.get(&id);
// has every anchor selected, and so does every path under the earlier rule.
let selected = sel.anchors.get(&id).filter(|_| !earlier);
for (k, sp) in path.subpaths.iter_mut().enumerate() {
if focal.range((k, 0)..=(k, usize::MAX)).next().is_none() {
continue;
@@ -1629,13 +1643,20 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
}
}
return Ok(());
};
}
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("empty path".into()))?;
let closed = sp.closed;
// Each anchor's distance along the path from the grabbed one, the shorter way round a
// closed path: the lengths are measured once, as a path can have many anchors.
let pos = sp.anchor_arc_positions();
let total = pos.last().copied().unwrap_or(0.0);
// The default reach of the earlier rule: half the diagonal of the subpath's bounding box.
let r = radius.unwrap_or_else(|| {
let mut bp = vectorcraft_geom::BezPath::new();
sp.to_bezpath_into(&mut bp);
let b = vectorcraft_geom::Shape::bounding_box(&bp);
(b.width().hypot(b.height()) / 2.0).max(1.0)
});
let from = pos.get(grabbed).copied().unwrap_or(0.0);
for (i, a) in sp.anchors.iter_mut().enumerate() {
let w = if i == grabbed {
@@ -1669,6 +1690,7 @@ fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
return Err(EngineError::Other("no such segment".into()));
}
let idx = sp.insert_anchor(seg, t);
renumber_after_insert(sel, id, si, idx);
// A path that wasn't selected becomes the selection, its new anchor direct-selected, so
// the point added shows and can be edited at once (#4237).
if !sel.contains(id) {
+4 -3
View File
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Run Script",
[],
None,
"{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: {} (no prompts exist yet: only an empty object is taken), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; vc.args is args; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error)}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, fuelLeft, fuelUsed, hostCalls and the fields above",
"{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: {} (no prompts exist yet: only an empty object is taken), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file being run (empty for text) and $.includePath the #includepath list; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict off is taken; #strict on, #targetengine and #engine are refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error)}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script), fuelLeft, fuelUsed, hostCalls and the fields above",
always,
run
),
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Script Console",
[],
None,
"{} the Script Console's lines, oldest first: each run's start, its $.write and $.writeln output, its vc.log entries, its uncaught error as name:line:column: message, its end and a report of its time, host calls and slowest commands; a session keeps at most 2048 lines and 1 MiB of text, dropping the oldest, a line at most 4096 bytes and a script's name in its lines at most 256 bytes, each cut with … (VectorCraft's own limits); line feeds, carriage returns and the two together break lines, other control characters show as their Unicode Control Pictures → {lines: [{run: the run's number in the session, name, kind: start|output|log|error|end|report, text}], droppedLines, truncatedLines} (an end line's text is ended: finished|error|fuel|hostLimit|limit|stopped|internal)",
"{} the Script Console's lines, oldest first: each run's start, its $.write and $.writeln output, its vc.log entries, its uncaught error as name:line:column: message (name: file:line:column: message when it is in another file), its end and a report of its time, host calls and slowest commands; a session keeps at most 2048 lines and 1 MiB of text, dropping the oldest, a line at most 4096 bytes and a script's name in its lines at most 256 bytes, each cut with … (VectorCraft's own limits); line feeds, carriage returns and the two together break lines, other control characters show as their Unicode Control Pictures → {lines: [{run: the run's number in the session, name, kind: start|output|log|error|end|report, text}], droppedLines, truncatedLines} (an end line's text is ended: finished|error|fuel|hostLimit|limit|stopped|internal)",
always,
console_lines
),
@@ -162,7 +162,8 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(param("give exactly one of `path` and `source`")),
};
let name = given_name.map(str::to_string).or_else(|| path.map(super::fileio::file_name)).unwrap_or_else(|| "script".to_string());
s.script_start(script::Request { name: name.clone(), source: text, args, fuel })?;
let request = script::Request { name, name_given: given_name.is_some(), file: path.map(str::to_string), source: text, args, fuel };
let name = s.script_start(request)?;
if s.script_frames {
return Ok(json!({"running": true, "name": name}));
}
+13 -1
View File
@@ -252,6 +252,18 @@ pub(crate) fn selectable_ids(d: &Document, iso: Option<NodeId>) -> BTreeSet<Node
/// Whether [`walk_selectable`] visits `id`, read from the objects that hold it instead of a walk of
/// the document. `locks` is the document's [`Document::locked_artboards`].
pub(crate) fn is_selectable(d: &Document, iso: Option<NodeId>, locks: &LockedArtboards, id: NodeId) -> bool {
is_selectable_within(d, iso, Some(locks), id)
}
/// [`is_selectable`] without the locked artboards, for an edit's new selection: after the edit,
/// the session takes the art on a locked artboard out of the selection, except the new art the
/// edit made.
pub(crate) fn is_selectable_on_any_artboard(d: &Document, iso: Option<NodeId>, id: NodeId) -> bool {
is_selectable_within(d, iso, None, id)
}
/// [`is_selectable`], leaving out the locked artboards when `locks` is `None`.
fn is_selectable_within(d: &Document, iso: Option<NodeId>, locks: Option<&LockedArtboards>, id: NodeId) -> bool {
let Some(a) = d.ancestry(id) else { return false };
if !a.iter().all(|i| d.node(*i).is_some_and(|n| n.visible && !n.locked && !n.is_template())) {
return false;
@@ -262,7 +274,7 @@ pub(crate) fn is_selectable(d: &Document, iso: Option<NodeId>, locks: &LockedArt
None => {
// The object Select All takes: the outermost one that is not a layer.
let top = a.iter().filter_map(|i| d.node(*i)).find(|n| !n.is_layer());
top.is_some_and(|t| locks.is_empty() || !locks.holds(t))
top.is_some_and(|t| locks.is_none_or(|l| l.is_empty() || !l.holds(t)))
}
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), 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\" (#rrggbbaa and #rgba are refused in both), 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\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), 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), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), 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), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), 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), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), 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), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), 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\" (#rrggbbaa and #rgba are refused in both), 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\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), 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), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), 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), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), 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), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), 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
),
+85
View File
@@ -23,6 +23,9 @@
//! A path is checked, then opened: a process that can change the folders in between (swapping in
//! a link) can still win that race. The roots keep an agent to the files it was given; they don't
//! guard against other programs on the computer.
//!
//! [`ScriptScope`] holds the folders a running script's own files (its includes and `$.evalFile`)
//! are read from, inside the roots.
use std::cell::RefCell;
use std::ffi::OsString;
@@ -347,6 +350,88 @@ fn windows_name_refusal(path: &str) -> Option<&'static str> {
})
}
/// The folders a running script's own files are read from: the files its `#include` lines name and
/// the files it passes to `$.evalFile` (`Scope::Script`'s reads). VectorCraft's own rules: a script
/// run from a file may read inside that file's folder and the folders below it, and a script given as
/// text may read none. A path is refused when it has a `..` component, a Windows device name or
/// stream, or names a computer on the network, or when it leads (through its links, junctions
/// included) out of every granted folder. The automation roots ([`check_read`]) apply as well.
///
/// The file opened is the path with its links followed, and after reading, the path is followed
/// again and must still lead to the same place: a process that changes the folders twice in between
/// can still win that race.
#[derive(Clone, Debug, Default)]
pub(crate) struct ScriptScope {
/// The granted folders, made absolute with their links followed.
roots: Vec<PathBuf>,
}
/// Why a script's file was not read.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum ScriptRead {
/// There is no such file.
Missing,
/// The scope does not let the script read it.
Refused,
/// The file has more than the bytes asked for.
TooLarge,
/// The file system gave this error.
Failed(String),
}
impl ScriptScope {
/// The scope of a script run from a file in folder `dir`: that folder.
pub(crate) fn of_folder(dir: &Path) -> Self {
Self { roots: std::fs::canonicalize(dir).ok().into_iter().collect() }
}
/// Is `path` refused by its name alone: a `..` component, a NUL, a Windows device name or
/// stream, or a computer on the network?
fn refused_by_name(path: &Path) -> bool {
let text = path.to_string_lossy();
text.contains('\0')
|| path.components().any(|c| c == Component::ParentDir)
|| (cfg!(windows) && (windows_name_refusal(&text).is_some() || network_path(&text)))
}
/// The real path of the file at `path` and its first `max` bytes, at most.
pub(crate) fn read(&self, path: &Path, max: usize) -> Result<(PathBuf, Vec<u8>), ScriptRead> {
use std::io::Read;
if self.roots.is_empty() || Self::refused_by_name(path) || check_read(&path.to_string_lossy()).is_err() {
return Err(ScriptRead::Refused);
}
let real = match std::fs::canonicalize(path) {
Ok(real) => real,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
// A link whose target is missing is there all the same: it is not followed.
return Err(if std::fs::symlink_metadata(path).is_ok() { ScriptRead::Refused } else { ScriptRead::Missing });
}
Err(e) => return Err(ScriptRead::Failed(e.to_string())),
};
if !self.roots.iter().any(|r| inside(&real, r)) {
return Err(ScriptRead::Refused);
}
let file = std::fs::File::open(&real).map_err(|e| ScriptRead::Failed(e.to_string()))?;
let meta = file.metadata().map_err(|e| ScriptRead::Failed(e.to_string()))?;
if !meta.is_file() {
return Err(ScriptRead::Missing);
}
if meta.len() > max as u64 {
return Err(ScriptRead::TooLarge);
}
let mut bytes = Vec::new();
// One byte more than asked for shows a file that grew while it was read.
file.take(max as u64 + 1).read_to_end(&mut bytes).map_err(|e| ScriptRead::Failed(e.to_string()))?;
if bytes.len() > max {
return Err(ScriptRead::TooLarge);
}
if std::fs::canonicalize(path).ok().as_ref() != Some(&real) {
return Err(ScriptRead::Refused);
}
Ok((real, bytes))
}
}
#[cfg(test)]
mod tests {
use super::*;
+431
View File
@@ -0,0 +1,431 @@
//! A script's preprocessor lines, read before it runs, as the public guide lists them
//! (<https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/>):
//! `#include` and `//@include` put the text of another file in place of the line, `#includepath`
//! sets the folders `#include` looks in, `#script` names the run, and `#target` and `#strict off`
//! are accepted. The same lines are read in a file `$.evalFile` runs.
//!
//! VectorCraft's own rules:
//!
//! - A line counts only at the start of a line (blanks before it), outside strings and comments, as
//! the lexer reads it, and each file must be a whole script on its own.
//! - An included file is looked for beside the file that includes it, then in each `#includepath`
//! folder in turn (a relative one is relative to that same file's folder), then in each folder of
//! the `JSINCLUDE` environment variable. A later `#includepath` replaces the list.
//! - Files are read through the script's [`ScriptScope`]: a refused path ends the run, except a
//! `JSINCLUDE` folder outside the scope, which is passed over.
//! - `#target` takes any value and changes nothing. `#targetengine`, `#engine` and `#strict on` are
//! refused as unsupported, and any other `#name` as unknown; `//@name` with another name stays a
//! comment.
//! - A run reads at most [`MAX_DEPTH`] levels of nested files, [`MAX_FILES`] files,
//! [`MAX_TOTAL_BYTES`] of source in all (the script's own included) and 8 MiB a file, and looks up
//! at most [`MAX_PROBES`] paths. A file may be included again once its earlier inclusion has
//! ended; one that includes itself, directly or not, is an error.
//!
//! The expanded source keeps a [`SourceMap`], so an error is reported at the line and column of
//! the file it is in.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use vectorcraft_js::{DirectiveName, Options};
use crate::file_access::{ScriptRead, ScriptScope};
/// The most files nested inside each other, below the script.
pub const MAX_DEPTH: usize = 16;
/// The most files a run includes or evaluates.
pub const MAX_FILES: usize = 64;
/// The most bytes of source a run reads, its own included.
pub const MAX_TOTAL_BYTES: usize = 32 << 20;
/// The most paths a run looks up for its files.
pub const MAX_PROBES: usize = 1024;
/// The longest value a preprocessor line may have, in bytes.
const MAX_VALUE_BYTES: usize = 4096;
/// Where the lines of an expanded source came from: file 0 is the file expanded, the others the
/// files it included.
#[derive(Clone, Debug, Default)]
pub(crate) struct SourceMap {
files: Vec<Arc<str>>,
segments: Vec<Segment>,
}
/// From line `out_line` of the expanded source on, the lines are those of file `file` from line
/// `line`; on `out_line` itself the columns are `shift` units further right.
#[derive(Clone, Copy, Debug)]
struct Segment {
out_line: u32,
file: usize,
line: u32,
shift: u32,
}
impl SourceMap {
/// Line `line`, column `column` of the expanded source → the file it is in (its index and name)
/// and the line and column there.
pub(crate) fn locate(&self, line: u32, column: u32) -> (usize, Arc<str>, u32, u32) {
let at = self.segments.partition_point(|s| s.out_line <= line);
let Some(seg) = at.checked_sub(1).and_then(|i| self.segments.get(i)) else {
return (0, self.files.first().cloned().unwrap_or_else(|| Arc::from("")), line, column);
};
let column = if line == seg.out_line { column.saturating_sub(seg.shift).max(1) } else { column };
let name = self.files.get(seg.file).cloned().unwrap_or_else(|| Arc::from(""));
(seg.file, name, seg.line.saturating_add(line - seg.out_line), column)
}
}
/// How a preprocessor failure ends the run.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum FailKind {
/// A line that is not a valid preprocessor line, or a file that is not a valid script.
Syntax,
/// A file that is missing, refused or unreadable, or a cycle.
Error,
/// One of the run's limits.
Limit,
}
/// A preprocessor failure, at line `line`, column `column` of the file `file` (index `index` of
/// the expansion's [`SourceMap`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Failure {
pub kind: FailKind,
pub message: String,
pub index: usize,
pub file: Arc<str>,
pub line: u32,
pub column: u32,
}
/// A source with its preprocessor lines read: the text to run, where its lines came from, and the
/// name its `#script` line gave.
pub(crate) struct Expanded {
pub source: String,
pub map: SourceMap,
pub name: Option<String>,
}
/// A file being run: its name for people and the folder its relative paths start from (none for a
/// script given as text).
struct Frame {
shown: Arc<str>,
dir: Option<PathBuf>,
}
/// The files of one run: its scope, the `#includepath` list, what it has read and the files open
/// now, innermost last.
pub(crate) struct Files {
scope: ScriptScope,
jsinclude: Vec<PathBuf>,
include_path: Vec<String>,
files: usize,
bytes: usize,
probes: usize,
/// The real paths of the files open now, for cycles.
chain: Vec<PathBuf>,
/// How many of them are below the script.
levels: usize,
/// The files `$.evalFile` is running, the script first.
frames: Vec<Frame>,
}
/// The output being built: its UTF-16 text, the line it is on, the units since that line began,
/// and whether its last unit is a carriage return.
struct Out {
units: Vec<u16>,
line: u32,
column: u32,
after_cr: bool,
map: SourceMap,
}
impl Out {
fn emit(&mut self, text: &[u16]) {
for &u in text {
match u {
0x0A if self.after_cr => self.column = 0,
0x0A | 0x0D | 0x2028 | 0x2029 => {
self.line = self.line.saturating_add(1);
self.column = 0;
}
_ => self.column = self.column.saturating_add(1),
}
self.after_cr = u == 0x0D;
}
self.units.extend_from_slice(text);
}
/// The lines from here on come from file `file`, from its line `line`.
fn begin(&mut self, file: usize, line: u32) {
self.map.segments.push(Segment { out_line: self.line, file, line, shift: self.column });
}
/// Ends the line the output is on, unless it has just ended.
fn end_line(&mut self) {
if self.after_cr || !matches!(self.units.last(), Some(0x0A | 0x2028 | 0x2029)) {
self.emit(&[0x0A]);
}
}
}
/// A file's text read as a script: a byte order mark is left out, and compiled scripts and bytes
/// that are not UTF-8 are refused.
fn decode(bytes: &[u8], value: &str) -> Result<String, String> {
if bytes.starts_with(b"@JSXBIN@") {
return Err(format!("script file is compiled: {value}"));
}
let text = std::str::from_utf8(bytes).map_err(|_| format!("script file is not UTF-8 text: {value}"))?;
Ok(text.strip_prefix('\u{feff}').unwrap_or(text).to_string())
}
/// `value` without the single or double quotes around it, or `None` when a quote is not closed.
fn unquote(value: &str) -> Option<&str> {
match value.chars().next() {
Some(q @ ('"' | '\'')) => value.get(1..).and_then(|v| v.strip_suffix(q)),
_ => Some(value),
}
}
/// The folder the file at `path` is in, without Windows' `\\?\` prefix: under that prefix `..` and
/// `/` in the names joined to it would be read as plain characters.
fn folder_of(path: &Path) -> Option<PathBuf> {
path.parent().map(|p| crate::cmd::findfiles::plain(p.to_path_buf()))
}
/// Does the path `p` have a `..` name, with `/` or `\` between names?
fn has_parent(p: &str) -> bool {
p.split(['/', '\\']).any(|name| name == "..")
}
/// A real path as people read it.
fn display(path: &Path) -> Arc<str> {
Arc::from(crate::cmd::findfiles::plain(path.to_path_buf()).to_string_lossy().as_ref())
}
impl Files {
/// The files of a run of the script at `path` (`None` for a script given as text), with the
/// `JSINCLUDE` folders `jsinclude`.
pub(crate) fn new(path: Option<&str>, jsinclude: Vec<PathBuf>) -> Self {
let real = path.and_then(|p| std::fs::canonicalize(p).ok());
let dir = real.as_deref().and_then(folder_of);
Self {
scope: dir.as_deref().map(ScriptScope::of_folder).unwrap_or_default(),
jsinclude,
include_path: Vec::new(),
files: 0,
bytes: 0,
probes: 0,
chain: real.into_iter().collect(),
levels: 0,
frames: vec![Frame { shown: Arc::from(path.unwrap_or_default()), dir }],
}
}
/// The `JSINCLUDE` folders of this process: absolute folders separated by `;`.
pub(crate) fn jsinclude_env() -> Vec<PathBuf> {
#[cfg(test)]
if let Some(list) = super::TEST_JSINCLUDE.with(|t| t.borrow().clone()) {
return list;
}
std::env::var("JSINCLUDE").map(|v| v.split(';').map(str::trim).filter(|p| !p.is_empty()).map(PathBuf::from).collect()).unwrap_or_default()
}
/// The file being run now, as `$.fileName` gives it (empty for a script given as text).
pub(crate) fn file_name(&self) -> String {
self.frames.last().map(|f| f.shown.to_string()).unwrap_or_default()
}
/// The `#includepath` list, as `$.includePath` gives it.
pub(crate) fn include_path(&self) -> String {
self.include_path.join(";")
}
/// The script's own text, `text`, with its preprocessor lines read.
pub(crate) fn expand_script(&mut self, text: &str) -> Result<Expanded, Failure> {
self.bytes = self.bytes.saturating_add(text.len());
let shown = self.frames.first().map(|f| f.shown.clone()).unwrap_or_else(|| Arc::from(""));
let dir = self.frames.first().and_then(|f| f.dir.clone());
self.expand(shown, dir, text, true)
}
/// The file `path` names for `$.evalFile`, relative to the file being run now, read and with its
/// preprocessor lines read. It becomes the file being run until [`Files::end_eval`].
pub(crate) fn eval_file(&mut self, path: &str) -> Result<Expanded, Failure> {
let current = self.frames.last().map(|f| f.shown.clone()).unwrap_or_else(|| Arc::from(""));
let fail = |kind, message: String| Failure { kind, message, index: 0, file: current.clone(), line: 0, column: 0 };
let dir = self.frames.last().and_then(|f| f.dir.clone());
let (real, bytes) = self.open(path, dir.as_deref(), false).map_err(|(kind, message)| fail(kind, message))?;
let text = self.admit(&real, bytes, path).map_err(|(kind, message)| fail(kind, message))?;
let shown = display(&real);
self.chain.push(real.clone());
self.levels += 1;
self.frames.push(Frame { shown: shown.clone(), dir: folder_of(&real) });
let r = self.expand(shown, folder_of(&real), &text, false);
if r.is_err() {
self.end_eval();
}
r
}
/// The file `$.evalFile` ran has ended.
pub(crate) fn end_eval(&mut self) {
if self.frames.len() > 1 {
self.frames.pop();
self.chain.pop();
self.levels = self.levels.saturating_sub(1);
}
}
/// Checks a file read at `real` against the run's limits and the files open now, and decodes
/// it. `value` is the path as the script wrote it.
fn admit(&mut self, real: &Path, bytes: Vec<u8>, value: &str) -> Result<String, (FailKind, String)> {
if self.chain.iter().any(|p| p == real) {
return Err((FailKind::Error, format!("include cycle: {value}")));
}
if self.levels >= MAX_DEPTH {
return Err((FailKind::Limit, format!("include depth limit ({MAX_DEPTH}) reached")));
}
if self.files >= MAX_FILES {
return Err((FailKind::Limit, format!("include file limit ({MAX_FILES}) reached")));
}
if self.bytes.saturating_add(bytes.len()) > MAX_TOTAL_BYTES {
return Err((FailKind::Limit, "script source limit reached (32 MiB)".to_string()));
}
self.files += 1;
self.bytes += bytes.len();
decode(&bytes, value).map_err(|m| (FailKind::Error, m))
}
/// Finds and reads the file `value` names, from the file in folder `dir`; with `search`, also in
/// the `#includepath` and `JSINCLUDE` folders.
fn open(&mut self, value: &str, dir: Option<&Path>, search: bool) -> Result<(PathBuf, Vec<u8>), (FailKind, String)> {
let path = Path::new(value);
let refused = || (FailKind::Error, format!("script file read refused: {value}"));
// A script given as text has no folder, and so no file it may read.
let Some(dir) = dir.filter(|_| !has_parent(value)) else { return Err(refused()) };
// Each candidate, and whether the scope's refusal passes it over.
let mut candidates: Vec<(Option<PathBuf>, bool)> = vec![(Some(dir.join(path)), false)];
if search && !path.is_absolute() {
candidates.extend(self.include_path.iter().map(|p| ((!has_parent(p)).then(|| dir.join(p).join(path)), false)));
candidates.extend(self.jsinclude.iter().filter(|p| p.is_absolute()).map(|p| (Some(p.join(path)), true)));
}
for (candidate, passed_over_when_refused) in candidates {
let Some(candidate) = candidate else { return Err(refused()) };
self.probes += 1;
if self.probes > MAX_PROBES {
return Err((FailKind::Limit, format!("include search limit ({MAX_PROBES} paths) reached")));
}
match self.scope.read(&candidate, crate::script::MAX_SOURCE_BYTES) {
Ok(found) => return Ok(found),
Err(ScriptRead::Missing) => {}
Err(ScriptRead::Refused) if passed_over_when_refused => {}
Err(ScriptRead::Refused) => return Err(refused()),
Err(ScriptRead::TooLarge) => return Err((FailKind::Limit, format!("script file is too large: {value}"))),
Err(ScriptRead::Failed(e)) => return Err((FailKind::Error, format!("cannot read script file {value}: {e}"))),
}
}
Err((FailKind::Error, format!("include file not found: {value}")))
}
/// `text`, the file `shown` in folder `dir`, with its preprocessor lines read. With `script`,
/// its `#script` line names the run.
fn expand(&mut self, shown: Arc<str>, dir: Option<PathBuf>, text: &str, script: bool) -> Result<Expanded, Failure> {
let mut out = Out { units: Vec::with_capacity(text.len()), line: 1, column: 0, after_cr: false, map: SourceMap::default() };
let mut name = None;
self.file(&mut out, shown, dir.as_deref(), text, script, &mut name)?;
let source = String::from_utf16(&out.units).map_err(|_| Failure {
kind: FailKind::Syntax,
message: "the script is not valid text".into(),
index: 0,
file: out.map.files.first().cloned().unwrap_or_else(|| Arc::from("")),
line: 0,
column: 0,
})?;
Ok(Expanded { source, map: out.map, name })
}
/// Adds file `shown` (in folder `dir`, with text `text`) to `out`, its included files in place
/// of their lines.
fn file(
&mut self,
out: &mut Out,
shown: Arc<str>,
dir: Option<&Path>,
text: &str,
script: bool,
name: &mut Option<String>,
) -> Result<(), Failure> {
let index = out.map.files.len();
out.map.files.push(shown.clone());
let units: Vec<u16> = text.encode_utf16().collect();
out.begin(index, 1);
// A source with neither `#` nor `@` has no preprocessor line.
if !units.iter().any(|&u| u == u16::from(b'#') || u == u16::from(b'@')) {
out.emit(&units);
return Ok(());
}
let fail = |kind, message: String, line, column| Failure { kind, message, index, file: shown.clone(), line, column };
let options = Options { preprocessor_lines: true, max_source_units: MAX_TOTAL_BYTES, ..Options::default() };
let program = vectorcraft_js::parse_utf16(&units, &options).map_err(|e| fail(FailKind::Syntax, e.message, e.line, e.column))?;
let mut cursor = 0usize;
for d in &program.directives {
let (start, end) = (d.span.start as usize, d.span.end as usize);
let line_start =
units.get(..start).and_then(|u| u.iter().rposition(|&c| matches!(c, 0x0A | 0x0D | 0x2028 | 0x2029))).map_or(0, |i| i + 1);
let column = u32::try_from(start - line_start + 1).unwrap_or(u32::MAX);
let at = |kind, message: String| fail(kind, message, d.line, column);
out.emit(units.get(cursor..start).unwrap_or_default());
cursor = end;
let label = format!("{}{}", d.form, d.name);
if d.value.len() > MAX_VALUE_BYTES {
return Err(at(FailKind::Syntax, format!("the value of {label} is too long")));
}
let value = unquote(&d.value).ok_or_else(|| at(FailKind::Syntax, format!("unterminated quote in {label}")))?;
match d.known {
Some(DirectiveName::Include) => {
if value.is_empty() {
return Err(at(FailKind::Syntax, format!("{label} needs a file name")));
}
let (real, bytes) = self.open(value, dir, true).map_err(|(kind, message)| at(kind, message))?;
let included = self.admit(&real, bytes, value).map_err(|(kind, message)| at(kind, message))?;
self.chain.push(real.clone());
self.levels += 1;
let r = self.file(out, display(&real), folder_of(&real).as_deref(), &included, false, name);
self.chain.pop();
self.levels -= 1;
r?;
out.end_line();
out.begin(index, d.line);
}
Some(DirectiveName::IncludePath) => {
self.include_path = value.split(';').map(str::trim).filter(|p| !p.is_empty()).map(str::to_string).collect();
}
Some(DirectiveName::Script) => {
if value.is_empty() {
return Err(at(FailKind::Syntax, format!("{label} needs a name")));
}
if script && index == 0 {
*name = Some(value.to_string());
}
}
Some(DirectiveName::Target) => {
if value.is_empty() {
return Err(at(FailKind::Syntax, format!("{label} needs a value")));
}
}
Some(DirectiveName::Strict) => match value.to_ascii_lowercase().as_str() {
"off" => {}
"on" => return Err(at(FailKind::Syntax, format!("unsupported strict checking: {label} on"))),
_ => return Err(at(FailKind::Syntax, format!("{label} takes on or off"))),
},
Some(DirectiveName::TargetEngine) => return Err(at(FailKind::Syntax, format!("unsupported script directive: {label}"))),
None if d.name.eq_ignore_ascii_case("engine") => {
return Err(at(FailKind::Syntax, format!("unsupported script directive: {label}")));
}
None => return Err(at(FailKind::Syntax, format!("unknown script directive: {label}"))),
}
}
out.emit(units.get(cursor..).unwrap_or_default());
Ok(())
}
}
+191 -27
View File
@@ -23,12 +23,18 @@
//! and the 64 levels, 16 MiB and 1,048,576 entries of [`vectorcraft_js::DataLimits`]'s default,
//! and the console's ([`console`]).
//!
//! Before a run starts, its preprocessor lines are read ([`include`]): included files take the
//! place of their lines, and an error in one is reported at its own file, line and column.
//! `$.evalFile(path)` runs another file in the same global environment and gives its last value,
//! `$.fileName` is the file being run and `$.includePath` the `#includepath` list.
//!
//! A run writes to the Script Console with `$.write` and `$.writeln`, and `vc.log` adds a line
//! there too. The session's console gets a start line, the run's output, its uncaught error, an end
//! line and a report line; the run's report holds its own output and error, its time, its host
//! calls by kind and its slowest commands.
pub mod console;
pub mod include;
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
@@ -133,7 +139,11 @@ pub(crate) fn refused_text(id: &str) -> String {
/// A request to run a script.
pub(crate) struct Request {
/// The run's name; unless `name_given`, a `#script` line in the script replaces it.
pub name: String,
pub name_given: bool,
/// The file the script was read from, which its included files are read beside.
pub file: Option<String>,
pub source: String,
pub args: Map<String, Value>,
pub fuel: u64,
@@ -470,8 +480,20 @@ struct Host {
commands: RefCell<HashMap<&'static str, (u64, u128, u128)>>,
/// The time the run's slices took, in nanoseconds.
run_nanos: Cell<u128>,
/// The files the run reads: its includes and `$.evalFile`'s.
files: RefCell<include::Files>,
/// Where the lines of the script's expanded source came from.
script_map: RefCell<include::SourceMap>,
/// The same for each source `$.evalFile` gave, in order.
eval_maps: RefCell<Vec<include::SourceMap>>,
/// The last error `$.evalFile` raised for a file it could not run, with the file, line and
/// column that error is at.
eval_failure: RefCell<Option<EvalFailure>>,
}
/// An error `$.evalFile` raised, as thrown, and the file, line and column it is at.
type EvalFailure = (String, Arc<str>, u32, u32);
/// A run's host calls by kind, as its report gives them in `hostCallCounts`.
#[derive(Default)]
struct Counts {
@@ -901,6 +923,8 @@ fn each_line(text: &str, mut done: impl FnMut(String, bool)) {
/// The `$` object: `$.write(...values)` writes the values to the Script Console as `String(value)`
/// gives them, one after another, and `$.writeln(...values)` does the same and ends the line.
/// `$.evalFile(path[, timeout])` runs a file and gives its last value, `$.fileName` is the file
/// being run and `$.includePath` the `#includepath` list.
fn dollar_object(host: &Rc<Host>) -> Vec<(String, HostItem)> {
let writer = |end: bool| {
let h = host.clone();
@@ -912,7 +936,67 @@ fn dollar_object(host: &Rc<Host>) -> Vec<(String, HostItem)> {
Ok(Data::Undefined)
}))
};
vec![("write".into(), writer(false)), ("writeln".into(), writer(true))]
let getter = |read: fn(&include::Files) -> String| {
let h = host.clone();
HostItem::Getter(function(move |_| {
let text = read(&h.files.borrow());
h.charge_call(text.len() as u64 + 2)?;
Ok(Data::String(text))
}))
};
let eval_file = {
let h = host.clone();
let end = host.clone();
HostItem::Evaluator { source: function(move |a| eval_file(&h, a)), end: Rc::new(move || end.files.borrow_mut().end_eval()) }
};
vec![
("write".into(), writer(false)),
("writeln".into(), writer(true)),
("evalFile".into(), eval_file),
("fileName".into(), getter(include::Files::file_name)),
("includePath".into(), getter(include::Files::include_path)),
]
}
/// `$.evalFile(path[, timeout])`: the source of the file `path` names, relative to the file being
/// run, with its preprocessor lines read, which the interpreter then runs. `timeout`, in
/// milliseconds, is taken and not waited on: the file is read at once.
fn eval_file(host: &Host, args: Vec<Data>) -> std::result::Result<Data, HostError> {
let Some(Data::String(path)) = args.first().filter(|p| !matches!(p, Data::String(s) if s.is_empty())) else {
return Err(HostError::Type(EVAL_NO_PATH.to_string()));
};
match args.get(1) {
None | Some(Data::Undefined) => {}
Some(Data::Number(ms)) if ms.is_finite() && *ms >= 0.0 => {}
Some(Data::Number(_)) => return Err(HostError::Range(EVAL_TIMEOUT.to_string())),
Some(_) => return Err(HostError::Type(EVAL_TIMEOUT.to_string())),
}
if args.len() > 2 {
return Err(HostError::Type(EVAL_ARGUMENTS.to_string()));
}
host.charge_call(path.len() as u64 + 2)?;
let expanded = host.files.borrow_mut().eval_file(path).map_err(|f| {
let message = if f.line > 0 { format!("{}:{}:{}: {}", f.file, f.line, f.column, f.message) } else { f.message };
if f.line > 0 {
*host.eval_failure.borrow_mut() = Some((message.clone(), f.file.clone(), f.line, f.column));
}
match f.kind {
include::FailKind::Limit => HostError::Abort(message),
include::FailKind::Syntax => HostError::Syntax(message),
include::FailKind::Error => HostError::Error(message),
}
})?;
// A file without preprocessor lines is parsed here first, so its syntax error names its place.
let options = vectorcraft_js::Options { max_source_units: include::MAX_TOTAL_BYTES, ..vectorcraft_js::Options::default() };
if let Err(e) = vectorcraft_js::parse(&expanded.source, &options) {
host.files.borrow_mut().end_eval();
let (_, file, line, column) = expanded.map.locate(e.line, e.column);
let message = format!("{file}:{line}:{column}: {}", e.message);
*host.eval_failure.borrow_mut() = Some((message.clone(), file, line, column));
return Err(HostError::Syntax(message));
}
host.eval_maps.borrow_mut().push(expanded.map);
Ok(Data::String(expanded.source))
}
fn function(f: impl Fn(Vec<Data>) -> std::result::Result<Data, HostError> + 'static) -> HostFunction {
@@ -976,21 +1060,67 @@ fn kind_id(kind: &RunErrorKind) -> &'static str {
}
/// `message`, with what to do about a failure that comes from a script written for another program.
fn explained(kind: &RunErrorKind, message: &str, source: &str, line: u32) -> String {
fn explained(kind: &RunErrorKind, message: &str) -> String {
const HOST_OBJECTS: &[&str] = &["app", "alert", "confirm", "prompt", "File", "Folder", "Window", "BridgeTalk", "activeDocument", "documents"];
let names_host_object =
message.strip_prefix("ReferenceError: ").is_some_and(|m| HOST_OBJECTS.iter().any(|o| m.split_whitespace().next() == Some(o)));
let directive =
*kind == RunErrorKind::Syntax && line > 0 && source.lines().nth(line as usize - 1).is_some_and(|l| l.trim_start().starts_with('#'));
if *kind == RunErrorKind::Thrown && names_host_object {
format!("{message} (scripts here use the `vc` object; objects other applications give their scripts are not available)")
} else if directive {
format!("{message} (preprocessor lines such as `#target` and `#include` are not read yet)")
} else {
message.to_string()
}
}
/// The error of a run that stopped in its preprocessor lines (`f`), as the realm gives its own.
fn preprocessor_error(f: include::Failure) -> (RunError, Option<Arc<str>>) {
let (kind, message) = match f.kind {
include::FailKind::Syntax if f.line > 0 => {
(RunErrorKind::Syntax, format!("SyntaxError: {} (line {}, column {})", f.message, f.line, f.column))
}
include::FailKind::Syntax => (RunErrorKind::Syntax, format!("SyntaxError: {}", f.message)),
include::FailKind::Error => (RunErrorKind::Thrown, format!("Error: {}", f.message)),
include::FailKind::Limit => (RunErrorKind::Limit(LimitKind::Host), f.message),
};
let file = (f.index > 0).then_some(f.file);
(RunError { kind, message, line: f.line, column: f.column }, file)
}
/// `e`, which the realm raised in source `tag` ([`Realm::error_source`]), at the line and column of
/// the file it is in, with that file's name unless it is the script's own.
fn located(host: &Host, tag: u32, e: &RunError) -> (RunError, Option<Arc<str>>) {
let map = |m: &include::SourceMap, own: bool| {
let (index, file, line, column) = m.locate(e.line, e.column);
let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, line, column) } else { e.message.clone() };
(RunError { kind: e.kind.clone(), message, line, column }, (!own || index > 0).then_some(file))
};
// An error `$.evalFile` raised for a file it could not run is at that file's place.
if let Some((_, file, line, column)) = host.eval_failure.borrow().as_ref().filter(|(thrown, ..)| e.message.contains(thrown.as_str())) {
let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, *line, *column) } else { e.message.clone() };
return (RunError { kind: e.kind.clone(), message, line: *line, column: *column }, Some(file.clone()));
}
if e.line == 0 {
return (e.clone(), None);
}
match tag {
0 => map(&host.script_map.borrow(), true),
n if n != vectorcraft_js::UNKNOWN_SOURCE => match host.eval_maps.borrow().get(n as usize - 1) {
Some(m) => map(m, false),
None => (e.clone(), None),
},
_ => (e.clone(), None),
}
}
/// A syntax error's `message`, which ends with its place `(line L, column C)`, with the place
/// given as `line`, `column`.
fn relocated(message: &str, old_line: u32, old_column: u32, line: u32, column: u32) -> String {
let old = format!(" (line {old_line}, column {old_column})");
match message.strip_suffix(&old) {
Some(m) => format!("{m} (line {line}, column {column})"),
None => message.to_string(),
}
}
impl Session {
/// Is a script running? While one is, the commands that change anything are refused.
pub fn script_running(&self) -> bool {
@@ -1093,8 +1223,8 @@ impl Session {
Ok(())
}
/// Begins a run of `request`.
pub(crate) fn script_start(&mut self, request: Request) -> Result<()> {
/// Begins a run of `request`. → the run's name.
pub(crate) fn script_start(&mut self, request: Request) -> Result<String> {
self.settle_for_script()?;
// In a build where a fault ends the program, every unsaved document has a copy first.
if self.script_copy_first && self.docs.iter().any(DocState::is_dirty) && cmd::recovery::save_copies(self).is_err() {
@@ -1105,8 +1235,14 @@ impl Session {
self.script_high_water.retain(|uid, _| open.contains(uid));
self.script_stop.clear();
self.script_report = None;
let mut files = include::Files::new(request.file.as_deref(), include::Files::jsinclude_env());
let expanded = files.expand_script(&request.source);
let name = match &expanded {
Ok(include::Expanded { name: Some(name), .. }) if !request.name_given => name.clone(),
_ => request.name,
};
let limits = Limits { fuel: request.fuel, heap_bytes: HEAP_BYTES, ..Limits::default() };
let options = Options { max_source_units: MAX_SOURCE_BYTES.saturating_mul(2), ..Options::default() };
let options = Options { max_source_units: include::MAX_TOTAL_BYTES.saturating_mul(2), ..Options::default() };
let mut realm = Realm::with_options(options, limits);
realm.share_interrupt(&self.script_stop);
realm.set_data_limits(DataLimits::default());
@@ -1128,34 +1264,45 @@ impl Session {
log_bytes: Cell::new(0),
written: RefCell::new(Vec::new()),
run: self.script_runs,
name: console::line_name(&request.name),
name: console::line_name(&name),
console: RefCell::default(),
pending: RefCell::default(),
counts: Counts::default(),
commands: RefCell::default(),
run_nanos: Cell::new(0),
files: RefCell::new(files),
script_map: RefCell::default(),
eval_maps: RefCell::default(),
eval_failure: RefCell::default(),
});
let args = to_data(&Value::Object(request.args), &DataLimits::default(), 0, &mut (0, 0))
.map_err(|place| EngineError::BadParams { cmd: "script.run".into(), msg: format!("`args{place}` is nested more than 64 levels deep") })?;
let line = console::ConsoleLine { run: host.run, name: host.name.clone(), entry: console::Entry::Start };
self.script_console.push(line, false);
let started = realm
.define_host_object("vc", vc_object(&host, args))
.and_then(|()| realm.define_host_object("$", dollar_object(&host)))
.and_then(|()| realm.start(&request.source));
if let Err(e) = started {
let mut report = failure_report(&request.name, &e, &request.source, &host, 0, 0);
let started = match expanded {
Err(f) => Err(preprocessor_error(f)),
Ok(expanded) => {
*host.script_map.borrow_mut() = expanded.map;
realm
.define_host_object("vc", vc_object(&host, args))
.and_then(|()| realm.define_host_object("$", dollar_object(&host)))
.and_then(|()| realm.start(&expanded.source))
.map_err(|e| located(&host, realm.error_source(), &e))
}
};
if let Err((e, file)) = started {
let mut report = failure_report(&name, &e, file.as_deref(), &host, 0, 0);
close_run(self, &host, &mut report, ending(&e.kind));
let message = failure_text(&report);
self.script_report = Some(report);
return Err(EngineError::Other(message));
}
let scope = crate::file_access::ScopeHandle::capture();
let job = Job { realm, host, world: World::working(self), start_uids: open, name: request.name, scope };
let job = Job { realm, host, world: World::working(self), start_uids: open, name: name.clone(), scope };
let id = NEXT_JOB.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
JOBS.with(|jobs| jobs.borrow_mut().insert(id, job));
self.script_key = Some(JobKey(id));
Ok(())
Ok(name)
}
/// Advances the running script by one slice. `None` while it is still running; otherwise the
@@ -1196,7 +1343,8 @@ impl Session {
fn script_failed(&mut self, job: Job, e: &RunError) -> Result<Value> {
let fuel_left = job.realm.fuel_left();
let mut report = failure_report(&job.name, e, "", &job.host, fuel_left, job.realm.limits().fuel.saturating_sub(fuel_left));
let (e, file) = located(&job.host, job.realm.error_source(), e);
let mut report = failure_report(&job.name, &e, file.as_deref(), &job.host, fuel_left, job.realm.limits().fuel.saturating_sub(fuel_left));
close_run(self, &job.host, &mut report, ending(&e.kind));
drop(job);
raise_ids(self);
@@ -1310,13 +1458,16 @@ const TOO_MANY_ARGUMENTS: &str = "vc.run: takes a command id and an object of pa
const PARAMS_NOT_OBJECT: &str = "vc.run: the parameters must be an object";
const LOG_FULL: &str = "vc.log: the log of this run is full";
const NO_RECOVERY_COPY: &str = "a recovery copy could not be written, so the script was not run";
const EVAL_NO_PATH: &str = "$.evalFile requires a file path";
const EVAL_TIMEOUT: &str = "$.evalFile: the timeout must be a finite number of milliseconds, 0 or more";
const EVAL_ARGUMENTS: &str = "$.evalFile: takes a file path and a timeout";
/// The report of a run that failed.
fn failure_report(name: &str, e: &RunError, source: &str, host: &Host, fuel_left: u64, fuel_used: u64) -> Value {
json!({
/// The report of a run that failed; `file` is the included file the error is in.
fn failure_report(name: &str, e: &RunError, file: Option<&str>, host: &Host, fuel_left: u64, fuel_used: u64) -> Value {
let mut report = json!({
"ok": false,
"kind": kind_id(&e.kind),
"message": explained(&e.kind, &e.message, source, e.line),
"message": explained(&e.kind, &e.message),
"name": name,
"line": e.line,
"column": e.column,
@@ -1325,7 +1476,11 @@ fn failure_report(name: &str, e: &RunError, source: &str, host: &Host, fuel_left
"fuelLeft": fuel_left,
"fuelUsed": fuel_used,
"hostCalls": host.calls.get(),
})
});
if let (Some(file), Some(o)) = (file, report.as_object_mut()) {
o.insert("file".into(), json!(file));
}
report
}
/// How the run that failed with `kind` ended, as its console end line gives it.
@@ -1363,8 +1518,11 @@ fn close_run(s: &mut Session, host: &Host, report: &mut Value, ending: console::
if report["ok"] == false {
let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0));
let message = report["message"].as_str().unwrap_or_default();
let text =
if line > 0 { format!("{}:{line}:{column}: {message}", host.name) } else { format!("{}: location unavailable: {message}", host.name) };
let text = match report["file"].as_str() {
Some(file) if line > 0 => format!("{}: {file}:{line}:{column}: {message}", host.name),
_ if line > 0 => format!("{}:{line}:{column}: {message}", host.name),
_ => format!("{}: location unavailable: {message}", host.name),
};
each_line(&text, |line, cut| line_to(s, host, console::Entry::Error(line), cut));
}
let timing = clock().is_some();
@@ -1388,7 +1546,11 @@ fn close_run(s: &mut Session, host: &Host, report: &mut Value, ending: console::
fn failure_text(report: &Value) -> String {
let s = |k: &str| report[k].as_str().unwrap_or_default();
let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0));
let at = if line > 0 { format!(" line {line}, column {column}") } else { String::new() };
let at = match report["file"].as_str() {
Some(file) if line > 0 => format!(" line {line}, column {column} of {file}"),
_ if line > 0 => format!(" line {line}, column {column}"),
_ => String::new(),
};
let written = report["written"].as_array().map_or(0, Vec::len);
let kept = if written > 0 {
let paths: Vec<&str> = report["written"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
@@ -1456,4 +1618,6 @@ thread_local! {
pub(crate) static PANIC_IN_SLICE: Cell<bool> = const { Cell::new(false) };
/// Tests: the objects a run may add in place of [`GROWTH_OBJECTS`].
pub(crate) static TEST_GROWTH: Cell<Option<u64>> = const { Cell::new(None) };
/// Tests: the `JSINCLUDE` folders in place of the environment's.
pub(crate) static TEST_JSINCLUDE: RefCell<Option<Vec<std::path::PathBuf>>> = const { RefCell::new(None) };
}
+8 -1
View File
@@ -135,11 +135,18 @@ fn too_large(max: u64) -> String {
format!("larger than {max} bytes")
}
/// Whether `old` holds exactly `bytes`. It is read no further than one byte past their length, so a
/// large file is not read into memory.
pub(crate) fn holds(old: impl Read, bytes: &[u8]) -> bool {
let mut read = vec![];
old.take(bytes.len() as u64 + 1).read_to_end(&mut read).is_ok() && read == bytes
}
/// Write `bytes` to `dir/file`, making the folder; nothing is written when the file has them
/// already. A failure is logged: remembering is best effort and never fails the command.
fn put(dir: &Path, file: &str, bytes: &[u8]) -> bool {
let path = dir.join(file);
if std::fs::read(&path).is_ok_and(|old| old == bytes) {
if std::fs::File::open(&path).is_ok_and(|old| holds(old, bytes)) {
return true;
}
let written = std::fs::create_dir_all(dir).and_then(|()| write_atomic(&path, bytes));
+30 -7
View File
@@ -152,6 +152,7 @@ mod saveoptions;
mod scalestrokes;
mod script_console;
mod script_folder;
mod script_include;
mod script_run;
mod scripts;
mod select_ids_first;
@@ -3218,9 +3219,8 @@ fn flare_rotation_turns_only_the_rays() {
fn reshape_moves_the_grabbed_point_and_its_neighbourhood() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
// Grab the middle of the top edge: a new anchor appears there and moves the full delta. The
// radius, about half the square's diagonal, bounds the reach.
s.execute("path.reshape", &json!({"id": r.0, "x": 50.0, "y": 0.0, "dx": 0.0, "dy": -40.0, "radius": 70.0})).unwrap();
// Grab the middle of the top edge: a new anchor appears there and moves the full delta.
s.execute("path.reshape", &json!({"id": r.0, "x": 50.0, "y": 0.0, "dx": 0.0, "dy": -40.0})).unwrap();
let st = s.doc().unwrap();
let pd = st.doc.node(r).unwrap().path_data().unwrap().clone();
assert_eq!(pd.anchor_count(), 5);
@@ -3479,7 +3479,7 @@ fn reshape_of_a_closed_path_selected_whole_moves_all_of_it() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let before = s.doc().unwrap().doc.node(r).unwrap().path_data().unwrap().clone();
s.execute("path.reshape", &json!({"id": r.0, "x": 0.0, "y": 0.0, "dx": 15.0, "dy": -40.0})).unwrap();
s.execute("path.reshape", &json!({"id": r.0, "x": 0.0, "y": 0.0, "dx": 15.0, "dy": -40.0, "focal": []})).unwrap();
let after = s.doc().unwrap().doc.node(r).unwrap().path_data().unwrap().clone();
assert_eq!(after.anchor_count(), before.anchor_count(), "a grab on an anchor adds none");
let drag = vectorcraft_geom::Vec2::new(15.0, -40.0);
@@ -3493,7 +3493,7 @@ fn reshape_leaves_the_anchors_that_are_not_selected_in_place() {
let mut s = session();
let id = reshape_open_path(&mut s, reshape_line_of_seven());
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2], [0, 3], [0, 4], [0, 5]], "mode": "set"})).unwrap();
s.execute("path.reshape", &json!({"id": id.0, "x": 350.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap();
s.execute("path.reshape", &json!({"id": id.0, "x": 350.0, "y": 0.0, "dx": 0.0, "dy": 100.0, "focal": []})).unwrap();
let y: Vec<f64> = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect();
assert_eq!((y[0], y[1], y[5], y[6]), (0.0, 0.0, 100.0, 0.0), "{y:?}");
// The stretch runs from the last anchor that is not selected, 280 from the focal point.
@@ -3503,6 +3503,29 @@ fn reshape_leaves_the_anchors_that_are_not_selected_in_place() {
}
}
/// A step with neither `focal` nor `radius`, as one recorded before focal points, plays as it did
/// then: the selection does not matter, and a closed path follows with the default falloff.
#[test]
fn reshape_without_focal_or_radius_keeps_the_earlier_rule() {
let mut s = session();
let id = reshape_open_path(&mut s, reshape_line_of_seven());
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2], [0, 3], [0, 4], [0, 5]], "mode": "set"})).unwrap();
s.execute("path.reshape", &json!({"id": id.0, "x": 350.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap();
let y: Vec<f64> = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect();
assert_eq!((y[0], y[5], y[6]), (0.0, 100.0, 0.0), "{y:?}");
// Anchor 1 is not selected and still takes its share, measured from the end 350 away.
for i in 1..5 {
let u = i as f64 / 5.0;
assert!((y[i] - 100.0 * reshape_share(u)).abs() < 1e-9, "anchor {i} takes {u} of the way: {y:?}");
}
// `focal: null` is the same as leaving it out.
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
s.execute("path.reshape", &json!({"id": r.0, "x": 0.0, "y": 0.0, "dx": 0.0, "dy": -40.0, "focal": null})).unwrap();
let pts: Vec<Point> = s.doc().unwrap().doc.node(r).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| a.p).collect();
assert!(pts.contains(&Point::new(0.0, -40.0)), "{pts:?}");
assert!(pts.contains(&Point::new(100.0, 100.0)), "the far corner stays: {pts:?}");
}
#[test]
fn reshape_of_a_partly_selected_closed_path_stretches_between_the_anchors_that_stay() {
let mut s = session();
@@ -3517,7 +3540,7 @@ fn reshape_of_a_partly_selected_closed_path_stretches_between_the_anchors_that_s
);
// Anchors 5, 0, 1 and 2 are selected; 3 and 4 stay. The walk runs 4, 5, 0, 1, 2, 3.
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 5], [0, 0], [0, 1], [0, 2]], "mode": "set"})).unwrap();
s.execute("path.reshape", &json!({"id": id.0, "x": 0.0, "y": 0.0, "dx": 0.0, "dy": -50.0})).unwrap();
s.execute("path.reshape", &json!({"id": id.0, "x": 0.0, "y": 0.0, "dx": 0.0, "dy": -50.0, "focal": []})).unwrap();
let y: Vec<f64> = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect();
assert_eq!((y[0], y[3], y[4]), (-50.0, 100.0, 100.0), "{y:?}");
// From anchor 4 to the focal point is 200: anchor 5 is halfway. From anchor 3 back to it is
@@ -3572,7 +3595,7 @@ fn reshape_of_a_segment_keeps_a_selected_segment_as_its_two_halves() {
fn reshape_moves_only_the_subpaths_with_a_focal_point() {
let mut s = session();
let id = NodeId(s.execute("path.create", &json!({"d": "M0 0 L100 0 L200 0 M0 100 L100 100 L200 100"})).unwrap()["id"].as_u64().unwrap());
s.execute("path.reshape", &json!({"id": id.0, "x": 200.0, "y": 0.0, "dx": 0.0, "dy": 40.0})).unwrap();
s.execute("path.reshape", &json!({"id": id.0, "x": 200.0, "y": 0.0, "dx": 0.0, "dy": 40.0, "focal": []})).unwrap();
let pd = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone();
let y: Vec<Vec<f64>> = pd.subpaths.iter().map(|sp| sp.anchors.iter().map(|a| a.p.y).collect()).collect();
assert_eq!(y[1], vec![100.0, 100.0, 100.0], "{y:?}");
+22
View File
@@ -647,6 +647,28 @@ fn edits_change_the_active_row_of_the_first_selected_object() {
assert!(!e.contains("internal error"), "{e}");
}
/// After an aligned Remove Item or Duplicate Item, the active row follows the rows that changed in
/// the first selected object, also when that object paints above the others.
#[test]
fn aligned_remove_and_duplicate_keep_the_active_row_of_the_first_selected_object() {
let mut s = session();
let (_, b) = reversed_pair(&mut s);
run(&mut s, "appearance.setActiveItem", json!({"index": 0})); // b's red fill
// a's row 0 is b's white fill, row 1, so b's red fill stays at row 0.
run(&mut s, "appearance.removeItem", json!({"indices": [0], "aligned": true}));
assert_eq!(node(&s, b).appearance.items.len(), 2);
assert_eq!(s.appearance_item(), Some(0));
assert_eq!(fill_paint(&node(&s, b), 0).color().unwrap().to_hex(), "#ff0000");
let mut s = session();
let (_, b) = reversed_pair(&mut s);
run(&mut s, "appearance.setActiveItem", json!({"index": 1})); // b's white fill, a's row 0
// The copy goes above b's white fill, at 2; the active row stays on the original.
run(&mut s, "appearance.duplicateItem", json!({"indices": [0], "aligned": true}));
assert_eq!(node(&s, b).appearance.items.len(), 4);
assert_eq!(s.appearance_item(), Some(1));
}
/// `effect.apply`'s reply names the new effect by its index in the object whose row `item` is,
/// also when the objects painted after it already have effects there.
#[test]
+25
View File
@@ -1386,6 +1386,31 @@ fn break_link_leaves_art_in_hidden_or_locked_groups_unselected() {
assert!(!a.contains(&hidden) && !a.contains(&locked), "{a:?}");
}
/// Break Link to Symbol on an instance just placed on a locked artboard selects the art that
/// replaces it, which stays new art there. The art of an older instance there, named by id, is not
/// selected.
#[test]
fn break_link_keeps_new_art_on_a_locked_artboard_selected() {
let mut s = session();
let (old, name, _) = make_symbol(&mut s);
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 400})).unwrap();
s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
select(&mut s, &[old]);
s.execute("object.move", &json!({"dx": 1000, "dy": 0})).unwrap();
assert!(s.doc().unwrap().selection.objects.is_empty(), "moved onto the locked artboard");
let inst = NodeId(s.execute("symbol.place", &json!({"name": name, "x": 1100, "y": 100})).unwrap()["id"].as_u64().unwrap());
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [inst], "placed art is selected");
let r = s.execute("symbol.breakLink", &json!({})).unwrap();
let art: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
assert_eq!(art.len(), 1, "{r}");
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), art, "the art that replaces it");
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), art, "the art stays editable");
let r = s.execute("symbol.breakLink", &json!({"ids": [old.0]})).unwrap();
assert_eq!(r["ids"].as_array().unwrap().len(), 1, "{r}");
assert!(s.doc().unwrap().selection.objects.is_empty(), "the older instance's art");
}
/// Delete Symbol, expanding the instances, keeps a hidden instance's art hidden and a locked
/// instance's art locked.
#[test]
+76
View File
@@ -2380,6 +2380,82 @@ fn add_anchor_point_selects_the_path_it_was_added_to() {
assert!(selected_anchors(&s, kept).is_empty(), "a path selected as a whole stays so");
}
fn path_from(s: &mut Session, d: &str) -> NodeId {
NodeId(s.execute("path.create", &json!({ "d": d })).unwrap()["id"].as_u64().unwrap())
}
fn selected_segments(s: &Session, id: NodeId) -> Vec<(usize, usize)> {
s.doc().unwrap().selection.segments.get(&id).map(|set| set.iter().copied().collect()).unwrap_or_default()
}
fn selected_points(s: &Session, id: NodeId) -> Vec<Point> {
let pd = path(s, id);
selected_anchors(s, id).iter().filter_map(|&(si, ai)| pd.subpaths.get(si)?.anchors.get(ai)).map(|a| a.p).collect()
}
#[test]
fn add_anchor_point_keeps_the_same_anchors_selected() {
// The direct-selected anchors after the one added are numbered one higher, so the selection
// stays on the same points; one undo step restores the path and the selection.
let mut s = session();
let id = path_from(&mut s, "M0 0 L70 0 L140 0 L210 0 L280 0 L350 0 L420 0");
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1], [0, 5]], "mode": "set"})).unwrap();
let before = selected_points(&s, id);
let r = s.execute("path.insertAnchor", &json!({"id": id.0, "segment": 2, "t": 0.5})).unwrap();
assert_eq!(r["anchor"], 3);
assert_eq!(selected_anchors(&s, id), [(0, 1), (0, 6)], "the anchor at 350 is now number 6");
assert_eq!(selected_points(&s, id), before);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, id).subpaths[0].anchors.len(), 7);
assert_eq!(selected_anchors(&s, id), [(0, 1), (0, 5)]);
}
#[test]
fn add_anchor_point_on_a_selected_segment_keeps_both_halves_and_the_new_anchor_selected() {
let mut s = session();
let id = path_from(&mut s, "M0 0 L70 0 L140 0 L210 0 L280 0 L350 0 L420 0");
s.execute("select.segments", &json!({"id": id.0, "segments": [[0, 2], [0, 4]], "mode": "set"})).unwrap();
s.execute("path.insertAnchor", &json!({"id": id.0, "segment": 2, "t": 0.5})).unwrap();
assert_eq!(selected_segments(&s, id), [(0, 2), (0, 3), (0, 5)]);
assert_eq!(selected_anchors(&s, id), [(0, 2), (0, 3), (0, 4), (0, 5), (0, 6)], "the new anchor is selected with the segment's ends");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, id).subpaths[0].anchors.len(), 7);
assert_eq!(selected_segments(&s, id), [(0, 2), (0, 4)]);
}
#[test]
fn add_anchor_point_on_the_closing_segment_keeps_it_selected_as_two_halves() {
let mut s = session();
let id = path_from(&mut s, "M0 0 L100 0 L100 100 L0 100 Z");
s.execute("select.segments", &json!({"id": id.0, "segments": [[0, 3]], "mode": "set"})).unwrap();
let r = s.execute("path.insertAnchor", &json!({"id": id.0, "segment": 3, "t": 0.5})).unwrap();
assert_eq!(r["anchor"], 4);
assert_eq!(selected_segments(&s, id), [(0, 3), (0, 4)]);
assert_eq!(selected_anchors(&s, id), [(0, 0), (0, 3), (0, 4)]);
assert_eq!(selected_points(&s, id), [Point::new(0.0, 0.0), Point::new(0.0, 100.0), Point::new(0.0, 50.0)]);
}
#[test]
fn add_anchor_point_renumbers_only_the_subpath_it_was_added_to() {
let mut s = session();
let id = path_from(&mut s, "M0 0 L100 0 L200 0 M0 100 L100 100 L200 100");
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2], [1, 1], [1, 2]], "mode": "set"})).unwrap();
let before = selected_points(&s, id);
s.execute("path.insertAnchor", &json!({"id": id.0, "subpath": 1, "segment": 0, "t": 0.5})).unwrap();
assert_eq!(selected_anchors(&s, id), [(0, 2), (1, 2), (1, 3)]);
assert_eq!(selected_points(&s, id), before);
}
#[test]
fn add_anchor_point_with_a_selected_anchor_index_past_the_end_does_not_panic() {
// `select.anchors` takes any index, so a selected one can name no anchor.
let mut s = session();
let id = path_from(&mut s, "M0 0 L70 0 L140 0 L210 0");
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1], [0, usize::MAX]], "mode": "set"})).unwrap();
s.execute("path.insertAnchor", &json!({"id": id.0, "segment": 0, "t": 0.5})).unwrap();
assert_eq!(selected_anchors(&s, id), [(0, 2), (0, usize::MAX)]);
}
fn selected_anchors(s: &Session, id: NodeId) -> Vec<(usize, usize)> {
s.doc().unwrap().selection.anchors.get(&id).map(|set| set.iter().copied().collect()).unwrap_or_default()
}
+23
View File
@@ -75,6 +75,29 @@ fn symbol_instance_tint_survives_expanding_an_object_effect() {
assert!(differing(&before, &after, 8) < 200, "expanding changed {} pixels", differing(&before, &after, 8));
}
/// The canvas tints a symbol's art with the instance fill before the instance's color adjustment,
/// and expanding gives the same colors.
#[test]
fn symbol_instance_tint_comes_before_a_color_adjustment_when_expanded() {
let mut s = session();
square(&mut s);
let id = NodeId(run(&mut s, "symbol.new", json!({"name": "Tinted Square"}))["id"].as_u64().unwrap());
run(&mut s, "appearance.addFill", json!({"ids": [id.0]}));
let fill = node(&s, id).appearance.items.len() - 1;
run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": fill, "color": "#00ff00", "opacity": 50}));
run(&mut s, "effect.apply", json!({"ids": [id.0], "item": null, "effect": "adjust.hueSaturation", "params": {"hue": 120}}));
let before = render(&s);
assert_ne!(before.pixel(100, 100), [255, 255, 255, 255], "the symbol is visible");
assert!(expandable(&s));
run(&mut s, "effect.expandAppearance", json!({}));
assert!(!matches!(node(&s, id).kind, NodeKind::SymbolInstance { .. }));
let after = render(&s);
let (b, a) = (before.pixel(100, 100), after.pixel(100, 100));
assert!(b.iter().zip(a.iter()).all(|(x, y)| x.abs_diff(*y) <= 8), "expanding changed the color from {b:?} to {a:?}");
assert!(differing(&before, &after, 8) < 200, "expanding changed {} pixels", differing(&before, &after, 8));
}
/// An instance fill with an opacity of 0 does not tint the symbol's art, so expanding keeps the
/// art's global swatch link.
#[test]
+334
View File
@@ -0,0 +1,334 @@
//! A script's preprocessor lines: `#include` and `//@include` in place, the folders they are looked
//! for in, the run's limits and scope, the other lines, `$.evalFile`, `$.fileName`, and errors at the
//! line of the file they are in.
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use crate::script::TEST_JSINCLUDE;
use crate::*;
/// A fresh folder for test `name`, with a `main` folder inside it for the script.
fn folder(name: &str) -> PathBuf {
let base = std::env::temp_dir().join(format!("vc-script-include-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("main")).unwrap();
base
}
fn write(path: &Path, text: &str) {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).unwrap();
}
std::fs::write(path, text).unwrap();
}
/// Runs `main/main.jsx` in `base`, with the text `text`.
fn run(base: &Path, text: &str) -> Result<Value> {
let main = base.join("main/main.jsx");
write(&main, text);
Session::new().execute("script.run", &json!({"path": main.to_string_lossy()}))
}
/// The report of the run that failed with `e`.
fn failed(base: &Path, text: &str) -> Value {
let mut s = Session::new();
let main = base.join("main/main.jsx");
write(&main, text);
let e = s.execute("script.run", &json!({"path": main.to_string_lossy()}));
assert!(e.is_err(), "the run failed: {e:?}");
s.script_report().unwrap().clone()
}
#[test]
fn script_include_sibling_runs_in_place() {
let base = folder("sibling");
write(&base.join("main/lib.jsxinc"), "var b = a + 1;");
write(&base.join("main/two.jsxinc"), "b = b * 10;\r\n");
let r = run(&base, "var a = 1;\n #include \"lib.jsxinc\"\n//@include 'two.jsxinc'\nvc.log(a + b);").unwrap();
assert_eq!(r["log"], json!([21]));
// A file may be included again once its earlier inclusion has ended.
let r = run(&base, "var n = 0;\n#include \"three.jsxinc\"\n#include \"three.jsxinc\"\nvc.log(n);");
assert!(r.is_err(), "three.jsxinc is missing");
write(&base.join("main/three.jsxinc"), "n++;");
let r = run(&base, "var n = 0;\n#include \"three.jsxinc\"\n#include three.jsxinc\nvc.log(n);").unwrap();
assert_eq!(r["log"], json!([2]));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn script_nested_include_uses_including_folder() {
let base = folder("nested");
write(&base.join("main/sub/one.jsxinc"), "#include \"two.jsxinc\"\nvar one = two + 1;");
write(&base.join("main/sub/two.jsxinc"), "var two = 2;");
write(&base.join("main/two.jsxinc"), "var two = 200;");
let r = run(&base, "#include \"sub/one.jsxinc\"\nvc.log(one);").unwrap();
assert_eq!(r["log"], json!([3]), "two.jsxinc beside one.jsxinc, not beside the script");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn script_missing_include_reports_origin() {
let base = folder("missing");
let report = failed(&base, "vc.log(1);\n\t#include \"nope.jsxinc\"\n");
assert_eq!(report["kind"], "thrown");
assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(2), Some(2)));
assert!(report["message"].as_str().unwrap().contains("include file not found: nope.jsxinc"), "{report}");
assert_eq!(report["log"], json!([]), "nothing ran before the include was read");
assert!(report.get("file").is_none(), "the line is the script's own");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn script_includepath_searches_in_order() {
let base = folder("includepath");
write(&base.join("main/a/x.jsxinc"), "var x = 'a';");
write(&base.join("main/b/x.jsxinc"), "var x = 'b';");
write(&base.join("main/b/y.jsxinc"), "var y = 'b';");
let r = run(&base, "#includepath \"a; b\"\n#include \"x.jsxinc\"\n#include \"y.jsxinc\"\nvc.log(x + y + $.includePath);").unwrap();
assert_eq!(r["log"], json!(["aba;b"]));
// A later #includepath replaces the list.
let report = failed(&base, "#includepath \"b\"\n#includepath \"a\"\n#include \"y.jsxinc\"");
assert!(report["message"].as_str().unwrap().contains("include file not found: y.jsxinc"), "{report}");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn script_include_cycle_and_depth_stop() {
let base = folder("cycle");
write(&base.join("main/a.jsxinc"), "#include \"b.jsxinc\"");
write(&base.join("main/b.jsxinc"), "\n#include \"a.jsxinc\"");
let report = failed(&base, "#include \"a.jsxinc\"");
assert!(report["message"].as_str().unwrap().contains("include cycle: a.jsxinc"), "{report}");
assert_eq!(report["line"], 2);
assert!(report["file"].as_str().unwrap().ends_with("b.jsxinc"), "{report}");
// A script that includes itself.
let report = failed(&base, "#include \"main.jsx\"");
assert!(report["message"].as_str().unwrap().contains("include cycle"), "{report}");
for i in 0..20 {
write(&base.join(format!("main/d{i}.jsxinc")), &format!("#include \"d{}.jsxinc\"", i + 1));
}
let report = failed(&base, "#include \"d0.jsxinc\"");
assert_eq!(report["kind"], "limit");
assert!(report["message"].as_str().unwrap().contains("include depth limit (16) reached"), "{report}");
assert!(report["file"].as_str().unwrap().ends_with("d15.jsxinc"), "the 17th level is refused: {report}");
let _ = std::fs::remove_dir_all(base);
}
/// Makes `link` lead to the folder `target`: a junction on Windows, a symbolic link elsewhere.
/// `false` when the system does not allow it here.
fn link_folder(target: &Path, link: &Path) -> bool {
#[cfg(windows)]
{
let made = std::process::Command::new("cmd").args(["/C", "mklink", "/J"]).arg(link).arg(target).output().is_ok_and(|o| o.status.success());
made && std::fs::canonicalize(link).ok() == std::fs::canonicalize(target).ok()
}
#[cfg(unix)]
{
std::os::unix::fs::symlink(target, link).is_ok()
}
#[cfg(not(any(unix, windows)))]
{
let _ = (target, link);
false
}
}
#[test]
fn script_include_link_out_of_the_folder_is_refused() {
let base = folder("junction");
write(&base.join("outside/x.jsxinc"), "var leaked = 1;");
// `mklink` reads a `/` in its paths as a switch, so each name is joined on its own.
if !link_folder(&base.join("outside"), &base.join("main").join("away")) {
eprintln!("skipped: a folder link can't be made here");
let _ = std::fs::remove_dir_all(base);
return;
}
assert!(std::fs::canonicalize(base.join("main/away/x.jsxinc")).unwrap().starts_with(std::fs::canonicalize(base.join("outside")).unwrap()));
let report = failed(&base, "#include \"away/x.jsxinc\"\nvc.log(leaked);");
assert!(report["message"].as_str().unwrap().contains("script file read refused: away/x.jsxinc"), "{report}");
let report = failed(&base, "$.evalFile('away/x.jsxinc');");
assert!(report["message"].as_str().unwrap().contains("script file read refused: away/x.jsxinc"), "{report}");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn script_include_parent_component_refused() {
let base = folder("parent");
write(&base.join("outside.jsxinc"), "var leaked = 1;");
write(&base.join("main/sub/x.jsxinc"), "var x = 1;");
let report = failed(&base, "#include \"../outside.jsxinc\"");
assert!(report["message"].as_str().unwrap().contains("script file read refused: ../outside.jsxinc"), "{report}");
// Refused even where the path would come back inside the folder.
let report = failed(&base, "#include \"sub/../sub/x.jsxinc\"");
assert!(report["message"].as_str().unwrap().contains("script file read refused"), "{report}");
let report = failed(&base, "#includepath \"..\"\n#include \"outside.jsxinc\"");
assert!(report["message"].as_str().unwrap().contains("script file read refused: outside.jsxinc"), "{report}");
let absolute = base.join("outside.jsxinc").to_string_lossy().replace('\\', "/");
let report = failed(&base, &format!("#include \"{absolute}\""));
assert!(report["message"].as_str().unwrap().contains("script file read refused"), "an absolute path outside: {report}");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn script_jsinclude_outside_grants_ignored() {
let base = folder("jsinclude");
write(&base.join("outside/x.jsxinc"), "var x = 'outside';");
write(&base.join("main/lib/y.jsxinc"), "var y = 'lib';");
TEST_JSINCLUDE.with(|t| *t.borrow_mut() = Some(vec![base.join("outside"), base.join("main/lib")]));
let report = failed(&base, "#include \"x.jsxinc\"");
let r = run(&base, "#include \"y.jsxinc\"\nvc.log(y);");
TEST_JSINCLUDE.with(|t| *t.borrow_mut() = None);
assert!(report["message"].as_str().unwrap().contains("include file not found: x.jsxinc"), "passed over, not refused: {report}");
assert_eq!(r.unwrap()["log"], json!(["lib"]), "a JSINCLUDE folder inside the script's folder is searched");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn script_target_other_app_does_not_route() {
let mut s = Session::new();
let r = s.execute("script.run", &json!({"source": "#target someapp-12\n#target \"other\"\n//@target x\n#strict off\nvc.log(1);"})).unwrap();
assert_eq!(r["log"], json!([1]));
for (source, message) in [
("#target\nvc.log(1);", "#target needs a value"),
("#targetengine \"session\"", "unsupported script directive: #targetengine"),
("#engine \"main\"", "unsupported script directive: #engine"),
("#strict on", "unsupported strict checking: #strict on"),
("#strict maybe", "#strict takes on or off"),
("#frobnicate 1", "unknown script directive: #frobnicate"),
("#include \"x.jsxinc", "unterminated quote in #include"),
("#include \"x.jsxinc\"", "script file read refused: x.jsxinc"),
] {
let e = s.execute("script.run", &json!({"source": format!("vc.log(0);\n{source}")})).unwrap_err().to_string();
assert!(e.contains(message), "{source}: {e}");
assert_eq!(s.script_report().unwrap()["line"], 2, "{source}");
assert_eq!(s.script_report().unwrap()["log"], json!([]), "{source}");
}
// An unknown `//@` name stays a comment.
assert_eq!(s.execute("script.run", &json!({"source": "//@frobnicate 1\nvc.log(2);"})).unwrap()["log"], json!([2]));
}
#[test]
fn script_name_comes_from_the_script_line() {
let mut s = Session::new();
let r = s.execute("script.run", &json!({"source": "#script \"First\"\n#script Second\nvc.log($.fileName);"})).unwrap();
assert_eq!((r["name"].as_str(), r["log"].clone()), (Some("Second"), json!([""])));
let r = s.execute("script.run", &json!({"source": "#script Second\n", "name": "given.js"})).unwrap();
assert_eq!(r["name"], "given.js", "a name given to script.run wins");
let base = folder("name");
write(&base.join("main/lib.jsxinc"), "#script Inner\n");
let r = run(&base, "#include \"lib.jsxinc\"").unwrap();
assert_eq!(r["name"], "main.jsx", "an included file's #script line does not rename the run");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn script_strict_on_is_refused() {
let mut s = Session::new();
let e = s.execute("script.run", &json!({"source": "#strict on\nvc.log(1);"})).unwrap_err().to_string();
assert!(e.contains("unsupported strict checking: #strict on"), "{e}");
assert_eq!(s.script_report().unwrap()["kind"], "syntax");
}
#[test]
fn script_eval_file_returns_last_value_and_shares_globals() {
let base = folder("evalfile");
write(&base.join("main/sub/e.jsx"), "var inner = $.fileName;\nfunction h(x) { return x * g; }\n#include \"f.jsxinc\"\ng + f;");
write(&base.join("main/sub/f.jsxinc"), "var f = 40;");
write(&base.join("main/empty.jsx"), "");
let r = run(
&base,
"var g = 2;\nvar r = $.evalFile('sub/e.jsx', 1000);\nvc.log(r);\nvc.log(h(3));\nvc.log(/e\\.jsx$/.test(inner));\nvc.log(/main\\.jsx$/.test($.fileName));\nvc.log($.evalFile('empty.jsx') === undefined);",
)
.unwrap();
assert_eq!(r["log"], json!([42, 6, true, true, true]));
// Its errors can be caught; a file is evaluated again after it has returned.
let r = run(
&base,
"var g = 1; var out = [];\ntry { $.evalFile(''); } catch (e) { out.push(e.name); }\ntry { $.evalFile('nope.jsx'); } catch (e) { out.push(String(e)); }\ntry { $.evalFile('sub/e.jsx', -1); } catch (e) { out.push(e.name); }\ntry { $.evalFile('sub/e.jsx', Infinity); } catch (e) { out.push(e.name); }\nout.push($.evalFile('sub/e.jsx') + $.evalFile('sub/e.jsx'));\nvc.log(out);",
)
.unwrap();
assert_eq!(r["log"], json!([["TypeError", "Error: include file not found: nope.jsx", "RangeError", "TypeError", 82]]));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn script_eval_file_outside_scope_refused() {
let base = folder("evaloutside");
write(&base.join("outside.jsx"), "1;");
let absolute = base.join("outside.jsx").to_string_lossy().replace('\\', "/");
let script = format!(
"var out = [];\ntry {{ $.evalFile('../outside.jsx'); }} catch (e) {{ out.push(String(e)); }}\ntry {{ $.evalFile('{absolute}'); }} catch (e) {{ out.push(String(e)); }}\nvc.log(out);"
);
let r = run(&base, &script).unwrap();
let out = r["log"][0].as_array().unwrap();
assert_eq!(out.len(), 2);
for e in out {
assert!(e.as_str().unwrap().starts_with("Error: script file read refused"), "{e}");
}
// A script given as text has no folder to read from.
let e = Session::new().execute("script.run", &json!({"source": "$.evalFile('x.jsx');"})).unwrap_err().to_string();
assert!(e.contains("script file read refused: x.jsx"), "{e}");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn script_directives_in_strings_and_block_comments_are_inert() {
let mut s = Session::new();
let source = "#target x\nvar a = '#include \"nope.jsxinc\"';\n/*\n#include \"nope.jsxinc\"\n//@include \"nope.jsxinc\"\n*/\nvar b = 1; // #include \"nope.jsxinc\"\nvar c = 2; #include\nvc.log(a.length + b);";
let e = s.execute("script.run", &json!({"source": source})).unwrap_err().to_string();
// `#` after code on a line is not a preprocessor line: the script has a syntax error there.
assert!(e.contains("line 8"), "{e}");
let source = source.replace("var c = 2; #include\n", "");
assert_eq!(s.execute("script.run", &json!({"source": source})).unwrap()["log"], json!([23]));
}
#[test]
fn script_included_error_has_local_file_and_line() {
let base = folder("errors");
write(&base.join("main/lib.jsxinc"), "var x = 1;\n\n throw new Error('boom');\n");
write(&base.join("main/bad.jsxinc"), "var y = 1;\nvar = 2;\n");
write(&base.join("main/five.jsxinc"), "1;\n2;\n3;\n4;\n5;");
write(&base.join("main/sub/thrower.jsx"), "\n\nthrow new Error('deep');");
let mut s = Session::new();
let report = failed(&base, "var z = 0;\n#include \"lib.jsxinc\"");
assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(3), Some(5)), "{report}");
assert!(report["file"].as_str().unwrap().ends_with("lib.jsxinc"), "{report}");
let report = failed(&base, "#include \"bad.jsxinc\"");
assert_eq!((report["kind"].as_str(), report["line"].as_u64()), (Some("syntax"), Some(2)), "{report}");
assert!(report["file"].as_str().unwrap().ends_with("bad.jsxinc"), "{report}");
// A line of the script after an include keeps its own number.
let report = failed(&base, "#include \"five.jsxinc\"\n throw new Error('after');");
assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(2), Some(3)), "{report}");
assert!(report.get("file").is_none(), "{report}");
let report = failed(&base, "#include \"five.jsxinc\"\nvar = 1;");
assert_eq!((report["kind"].as_str(), report["line"].as_u64()), (Some("syntax"), Some(2)), "{report}");
// An included file that ends in a carriage return does not join the script's next line ending.
write(&base.join("main/cr.jsxinc"), "1;\r2;\r");
let report = failed(&base, "#include \"cr.jsxinc\"\n\n throw new Error('after');");
assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(3), Some(3)), "{report}");
assert!(report.get("file").is_none(), "{report}");
let report = failed(&base, "#include \"cr.jsxinc\"\r\nvar = 1;");
assert_eq!((report["kind"].as_str(), report["line"].as_u64()), (Some("syntax"), Some(2)), "{report}");
// An uncaught error in a file $.evalFile ran is at that file's line.
let report = failed(&base, "var a = 1;\n$.evalFile('sub/thrower.jsx');");
assert_eq!(report["line"], 3, "{report}");
assert!(report["file"].as_str().unwrap().ends_with("thrower.jsx"), "{report}");
// So is a file $.evalFile could not run: a syntax error, or a bad preprocessor line.
write(&base.join("main/sub/broken.jsx"), "var a = 1;\n\nvar = 2;");
write(&base.join("main/sub/directive.jsx"), "\n#frobnicate 1");
for (file, line) in [("broken.jsx", 3), ("directive.jsx", 2)] {
let report = failed(&base, &format!("var a = 1;\n$.evalFile('sub/{file}');"));
assert_eq!(report["line"], line, "{report}");
assert!(report["file"].as_str().unwrap().ends_with(file), "{report}");
}
// The console's error line names the file.
let main = base.join("main/main.jsx");
write(&main, "#include \"lib.jsxinc\"");
assert!(s.execute("script.run", &json!({"path": main.to_string_lossy()})).is_err());
let console = s.execute("script.console", &json!({})).unwrap();
let error = console["lines"].as_array().unwrap().iter().find(|l| l["kind"] == "error").unwrap()["text"].as_str().unwrap().to_string();
assert!(error.starts_with("main.jsx: ") && error.contains("lib.jsxinc:3:5: Error: boom"), "{error}");
let _ = std::fs::remove_dir_all(base);
}
+10 -1
View File
@@ -166,7 +166,7 @@ fn an_error_names_the_script_its_place_and_kind() {
let e = run(&mut s, "var = 1;").unwrap_err().to_string();
assert!(e.starts_with("t.js: line 1,") && e.ends_with("(syntax)"), "{e}");
assert!(run(&mut s, "app.activeDocument;").unwrap_err().to_string().contains("`vc`"));
assert!(run(&mut s, "#target x\n1").unwrap_err().to_string().contains("#target"));
assert!(run(&mut s, "#targetengine x\n1").unwrap_err().to_string().contains("unsupported script directive: #targetengine"));
assert_eq!(s.script_report().unwrap()["line"], 1);
}
@@ -989,6 +989,15 @@ fn source_text() -> impl proptest::strategy::Strategy<Value = String> {
Just("{".to_string()),
Just("try {".to_string()),
Just("} catch (e) {}".to_string()),
Just("\n#include \"x.jsxinc\"\n".to_string()),
Just("\n//@include 'x\n".to_string()),
Just("\n#includepath \";;..;\"\n".to_string()),
Just("\n#script \"\"\n".to_string()),
Just("\n#target\n".to_string()),
Just("\n#strict on\n".to_string()),
Just("\n#\u{0}\n".to_string()),
Just("try { $.evalFile('x.jsx', NaN); } catch (e) {}".to_string()),
"\n#[a-z]{0,12}[ -~]{0,20}\n",
"[ -~]{0,30}",
"\\PC{0,10}",
];
+42 -1
View File
@@ -5,7 +5,7 @@ use serde_json::{Value, json};
use vectorcraft_color::cms;
use crate::Session;
use crate::store::{MAX_PRESET_FILE, file_stem};
use crate::store::{MAX_PRESET_FILE, file_stem, holds};
fn fresh_dir(tag: &str) -> std::path::PathBuf {
let dir = vectorcraft_testkit::temp_dir(&format!("store-{tag}"));
@@ -128,6 +128,47 @@ fn a_folder_that_does_not_exist_yet_takes_the_presets_the_preferences_held() {
assert_eq!(saved_names(&mut b), ["From Preferences"]);
}
/// Serves `0` bytes without end and counts how many were read.
struct Endless(u64);
impl std::io::Read for Endless {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.0 > 1 << 24 {
return Err(std::io::Error::other("read too far"));
}
buf.fill(0);
self.0 += buf.len() as u64;
Ok(buf.len())
}
}
#[test]
fn a_write_reads_the_file_it_replaces_only_one_byte_past_the_new_length() {
let mut old = Endless(0);
assert!(!holds(&mut old, &[0; 10]));
assert_eq!(old.0, 11);
assert!(holds(&[1, 2, 3][..], &[1, 2, 3]));
assert!(!holds(&[1, 2, 3, 4][..], &[1, 2, 3]));
assert!(!holds(&[1, 2][..], &[1, 2, 3]));
assert!(holds(&[][..], &[]));
}
#[test]
fn a_saved_preset_replaces_an_oversized_file_in_its_place() {
let dir = fresh_dir("oversized-put").join("PDF Presets");
let mut a = Session::new();
a.attach_pdf_preset_store(dir.clone());
a.execute("pdf.preset.save", &json!({"name": "Web"})).unwrap();
let file = dir.join(format!("{}.vcpdfpresets", file_stem("Web")));
let saved = std::fs::read(&file).unwrap();
let mut oversized = saved.clone();
oversized.resize(usize::try_from(MAX_PRESET_FILE).unwrap() * 2, b' ');
std::fs::write(&file, &oversized).unwrap();
a.execute("pdf.preset.save", &json!({"name": "Web"})).unwrap();
assert_eq!(std::fs::read(&file).unwrap(), saved);
}
#[test]
fn damaged_unsafe_and_oversized_preset_files_are_skipped() {
let dir = fresh_dir("damaged").join("PDF Presets");
+427 -3
View File
@@ -59,9 +59,11 @@ fn dimension_length_scale_live_edit_undo_and_roundtrip() {
run(&mut reopened, "object.scale", json!({"sx":50,"sy":100}));
assert_eq!(text(&reopened, id), "10.00 in");
run(&mut reopened, "edit.clear", json!({}));
assert!(reopened.doc().unwrap().doc.node(NodeId(id)).is_none());
assert_eq!(text(&reopened, id), "10.00 in", "deleting the art leaves the dimension");
assert!(recipe(&reopened, id).source.is_none(), "no longer linked");
run(&mut reopened, "edit.undo", json!({}));
assert_eq!(text(&reopened, id), "10.00 in");
assert!(recipe(&reopened, id).source.is_some(), "undo links it again");
}
/// Documents without dimensions skip the refresh, and lose the flag when the last goes.
@@ -567,6 +569,425 @@ fn dimension_anchor_links_and_native_file_survive_source_moves() {
assert_eq!(text(&s, id), "100 pt");
}
/// The anchors of path `id`'s first subpath.
fn anchors(s: &Session, id: u64) -> Vec<vectorcraft_geom::Point> {
let doc = &s.doc().unwrap().doc;
doc.node(NodeId(id)).unwrap().path_data().unwrap().subpaths[0].anchors.iter().map(|a| a.p).collect()
}
fn near(p: vectorcraft_geom::Point, q: vectorcraft_geom::Point) -> bool {
p.distance(q) < 1e-6
}
/// Clicks with the Dimension tool at each point in turn.
fn clicks(s: &mut Session, points: &[(f64, f64)]) -> u64 {
s.select_tool("dimension", ViewInfo::default()).unwrap();
for &(x, y) in points {
ev(s, PointerKind::Move, x, y);
ev(s, PointerKind::Down, x, y);
ev(s, PointerKind::Up, x, y);
}
s.doc().unwrap().selection.objects[0].0
}
/// A dimension from an anchor to a point on a straight segment follows the art at both ends: the
/// segment end keeps its place along the segment when the art is scaled, moved or reflected.
#[test]
fn a_dimension_to_a_point_on_a_segment_follows_the_art_at_both_ends() {
let mut s = session();
let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap();
let id = clicks(&mut s, &[(100.0, 100.0), (200.0, 150.0), (120.0, 90.0)]);
let length = recipe(&s, id).length;
for (command, p) in [
("object.scale", json!({"sx":150,"sy":150})),
("object.move", json!({"dx":40,"dy":-30})),
("object.rotate", json!({"angle":30})),
("object.reflect", json!({"axis":"vertical"})),
] {
run(&mut s, "select.set", json!({"ids":[rect]}));
run(&mut s, command, p);
let (d, at) = (recipe(&s, id), anchors(&s, rect));
assert!(near(d.a, at[0]), "{command}: {:?} {:?}", d.a, at[0]);
assert!(near(d.b, at[1].midpoint(at[2])), "{command}: {:?} {:?}", d.b, at[1].midpoint(at[2]));
assert!(d.source.is_some(), "{command}: still linked");
}
assert!((recipe(&s, id).length - 1.5 * length).abs() < 1e-6);
}
/// A point off the art is held against the anchors of the art at the other end, and moves with it.
#[test]
fn a_dimension_to_a_point_off_the_art_moves_with_the_art() {
let mut s = session();
let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap();
let id = clicks(&mut s, &[(200.0, 200.0), (330.0, 260.0), (260.0, 330.0)]);
let before = recipe(&s, id);
run(&mut s, "select.set", json!({"ids":[rect]}));
run(&mut s, "object.move", json!({"dx":40,"dy":-30}));
let d = recipe(&s, id);
let v = vectorcraft_geom::Vec2::new(40.0, -30.0);
assert!(near(d.a, before.a + v) && near(d.b, before.b + v), "{before:?} → {d:?}");
assert!((d.length - before.length).abs() < 1e-6 && (d.offset - before.offset).abs() < 1e-6);
run(&mut s, "object.scale", json!({"sx":200,"sy":200}));
assert!((recipe(&s, id).length - 2.0 * before.length).abs() < 1e-6);
}
/// When the art it measures is deleted or gets another number of anchors, a dimension stays where
/// it was, no longer linked: later edits of the art do not move it, and undo links it again.
#[test]
fn a_dimension_whose_art_changes_its_anchors_stays_where_it_was() {
let mut s = session();
let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap();
let id = clicks(&mut s, &[(100.0, 100.0), (200.0, 150.0), (120.0, 90.0)]);
let before = recipe(&s, id);
let label = text(&s, id);
run(&mut s, "path.insertAnchor", json!({"id":rect,"segment":3,"t":0.5}));
let d = recipe(&s, id);
assert!(d.source.is_none(), "no longer linked");
assert_eq!((d.a, d.b, d.offset, text(&s, id)), (before.a, before.b, before.offset, label.clone()));
run(&mut s, "select.set", json!({"ids":[rect]}));
run(&mut s, "object.scale", json!({"sx":150,"sy":150}));
assert_eq!((recipe(&s, id).a, recipe(&s, id).b), (before.a, before.b), "does not follow the art");
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(recipe(&s, id).source, before.source, "undo links it again");
run(&mut s, "select.set", json!({"ids":[rect]}));
run(&mut s, "edit.clear", json!({}));
assert!(s.doc().unwrap().doc.node(NodeId(rect)).is_none());
assert_eq!((recipe(&s, id).source, text(&s, id)), (None, label));
}
/// A linked dimension whose art is reflected has its line on the reflected side: a line above the
/// top edge is below that edge's points after a flip top to bottom.
#[test]
fn a_linked_dimension_of_reflected_art_has_its_line_on_the_reflected_side() {
use vectorcraft_doc::dimension::Orientation;
for orientation in ["aligned", "horizontal"] {
let mut s = session();
let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap();
let at = anchors(&s, rect);
let top = |p: vectorcraft_geom::Point| (p.y - 100.0).abs() < 1e-9;
let (i, j) = (at.iter().position(|p| top(*p)).unwrap(), at.iter().rposition(|p| top(*p)).unwrap());
let anchor = |anchor: usize| json!({"node":rect,"subpath":0,"anchor":anchor});
let src = json!({"kind":"points","a":anchor(i),"b":anchor(j)});
// The line 30 pt above the top edge: the offset is along the left normal of a → b when
// aligned, downward when horizontal.
let offset = if orientation == "aligned" && at[i].x > at[j].x { 30.0 } else { -30.0 };
let id = run(&mut s, "dimension.create", json!({"source":src,"orientation":orientation,"offset":offset}))["id"].as_u64().unwrap();
let line_y = |s: &Session| recipe(s, id).line_ends().0.y;
assert!((line_y(&s) - 70.0).abs() < 1e-6, "{orientation}: {}", line_y(&s));
run(&mut s, "select.set", json!({"ids":[rect]}));
run(&mut s, "object.reflect", json!({"axis":"horizontal"}));
let d = recipe(&s, id);
assert!(d.source.is_some());
assert!(((d.a.y, d.b.y) == (200.0, 200.0)), "{orientation}: {d:?}");
assert!((line_y(&s) - 230.0).abs() < 1e-6, "{orientation}: line at {}", line_y(&s));
let want = if orientation == "aligned" { Orientation::Aligned } else { Orientation::Horizontal };
assert_eq!(d.orientation, want);
run(&mut s, "edit.undo", json!({}));
assert!((line_y(&s) - 70.0).abs() < 1e-6);
}
}
/// Reflected across the line through the two points it measures, an arch keeps those points
/// where they were, and the dimension's line goes to the other side with the arch.
#[test]
fn a_dimension_of_art_reflected_across_its_points_changes_sides() {
let mut s = session();
let arch = json!([{"x":100,"y":200,"out":[100,150]},{"x":200,"y":200,"in":[200,150]}]);
let curve = run(&mut s, "path.create", json!({"anchors":arch,"closed":false}))["id"].as_u64().unwrap();
let anchor = |anchor: u64| json!({"node":curve,"subpath":0,"anchor":anchor});
let src = json!({"kind":"points","a":anchor(0),"b":anchor(1)});
let id = run(&mut s, "dimension.create", json!({"source":src,"offset":-30}))["id"].as_u64().unwrap();
assert!((recipe(&s, id).line_ends().0.y - 170.0).abs() < 1e-6);
run(&mut s, "select.set", json!({"ids":[curve]}));
run(&mut s, "object.reflect", json!({"axis":"horizontal","origin":[0,200]}));
let d = recipe(&s, id);
assert_eq!((d.a, d.b), (vectorcraft_geom::Point::new(100.0, 200.0), vectorcraft_geom::Point::new(200.0, 200.0)));
assert!((d.line_ends().0.y - 230.0).abs() < 1e-6, "below with the arch: {:?}", d.line_ends());
// Reflected together with the art, it ends in the same place.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "select.set", json!({"ids":[curve, id]}));
run(&mut s, "object.reflect", json!({"axis":"horizontal","origin":[0,200]}));
assert!((recipe(&s, id).line_ends().0.y - 230.0).abs() < 1e-6, "{:?}", recipe(&s, id).line_ends());
}
/// A dimension between two paths that are each reflected in their own way in one edit is not
/// mirrored by the map of one of them.
#[test]
fn a_dimension_between_paths_reflected_each_in_its_own_way_keeps_its_offset() {
let mut s = session();
let low = json!([{"x":100,"y":200,"out":[100,150]},{"x":200,"y":200,"in":[200,150]}]);
let high = json!([{"x":300,"y":200,"out":[300,100]},{"x":400,"y":200,"in":[400,100]}]);
let first = run(&mut s, "path.create", json!({"anchors":low,"closed":false}))["id"].as_u64().unwrap();
let second = run(&mut s, "path.create", json!({"anchors":high,"closed":false}))["id"].as_u64().unwrap();
let src = json!({"kind":"points","a":{"node":first,"subpath":0,"anchor":0},"b":{"node":second,"subpath":0,"anchor":1}});
let id = run(&mut s, "dimension.create", json!({"source":src,"offset":30}))["id"].as_u64().unwrap();
run(&mut s, "select.set", json!({"ids":[first, second]}));
run(&mut s, "object.transformEach", json!({"reflectY":true}));
let d = recipe(&s, id);
assert!(d.source.is_some() && (d.a.y - d.b.y).abs() > 1.0, "{d:?}");
assert!((d.offset - 30.0).abs() < 1e-6, "{d:?}");
}
/// A recipe's free points are checked: weights that do not add up to 1, or an end that is both an
/// anchor and a free point, are refused without changing the document.
#[test]
fn dimension_free_points_are_checked() {
let mut s = session();
let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap();
let original = serde_json::to_value(&*s.doc().unwrap().doc).unwrap();
let w = |anchor: u64, weight: f64| json!({"subpath":0,"anchor":anchor,"weight":weight});
let held = |anchors: Value, across: f64| json!({"kind":"anchors","node":rect,"anchors":anchors,"across":across});
let on = |segment: u64, t: f64| json!({"kind":"onSegment","edge":{"node":rect,"subpath":0,"segment":segment},"t":t});
let anchor = json!({"node":rect,"subpath":0,"anchor":0});
for free in [
held(json!([w(1, 0.5), w(2, 0.6)]), 0.0),
held(json!([w(1, 1.0)]), 0.0),
held(json!([w(1, 1e300), w(2, -1e300)]), 0.0),
held(json!([w(1, 0.5), w(99, 0.5)]), 0.0),
held(json!([w(1, 0.5), w(2, f64::MAX)]), 0.0),
held(json!([w(1, 0.5), w(2, 0.5)]), 1e300),
held(json!([w(1, 0.5), w(2, 0.25), w(3, 0.25)]), 0.5),
on(1, 1.5),
on(1, -0.1),
on(99, 0.5),
json!({"kind":"elsewhere","node":rect}),
] {
let p = json!({"source":{"kind":"points","a":anchor,"freeB":free}});
assert!(s.execute("dimension.create", &p).is_err(), "{p}");
assert_eq!(serde_json::to_value(&*s.doc().unwrap().doc).unwrap(), original);
}
let both = json!({"source":{"kind":"points","a":anchor,"freeA":held(json!([w(1, 0.5), w(2, 0.5)]), 0.0),"b":anchor}});
assert!(s.execute("dimension.create", &both).is_err());
let at = anchors(&s, rect);
for (free, want) in [(held(json!([w(1, 0.5), w(2, 0.5)]), 0.0), at[1].midpoint(at[2])), (on(1, 0.5), at[1].midpoint(at[2]))] {
let ok = json!({"source":{"kind":"points","a":anchor,"freeB":free}});
let id = run(&mut s, "dimension.create", ok)["id"].as_u64().unwrap();
assert!(near(recipe(&s, id).b, want), "{:?}", recipe(&s, id).b);
}
}
/// A point off a straight two-anchor line is held by its distance along and across the line: it
/// moves, turns and scales with the line.
#[test]
fn a_dimension_to_a_point_off_a_line_follows_the_line() {
let mut s = session();
let line = run(&mut s, "shape.line", json!({"x1":100,"y1":100,"x2":200,"y2":100}))["id"].as_u64().unwrap();
let id = clicks(&mut s, &[(100.0, 100.0), (150.0, 160.0), (60.0, 130.0)]);
let d = recipe(&s, id);
assert!(d.source.as_ref().is_some_and(|src| src.nodes() == vec![NodeId(line), NodeId(line)]), "{:?}", d.source);
run(&mut s, "dimension.update", json!({"ids":[id],"orientation":"aligned"}));
run(&mut s, "select.set", json!({"ids":[line]}));
run(&mut s, "object.rotate", json!({"angle":90,"origin":[100,100]}));
run(&mut s, "object.scale", json!({"sx":200,"sy":200,"origin":[100,100]}));
run(&mut s, "object.move", json!({"dx":10,"dy":20}));
let at = anchors(&s, line);
let (o, u) = (at[0], at[1] - at[0]);
// 50 along the line and 60 across it before, now in the line's new frame at twice the size.
let want = o + u * 0.5 + vectorcraft_geom::Vec2::new(-u.y, u.x) * 0.6;
let d = recipe(&s, id);
assert!(near(d.a, o) && near(d.b, want), "{d:?} want {want:?}");
assert!((d.length - 2.0 * (50.0f64.hypot(60.0))).abs() < 1e-6);
}
/// A point on a straight segment stays on the segment when its handles are edited into a curve.
#[test]
fn a_point_on_a_segment_stays_on_it_when_the_segment_curves() {
let mut s = session();
let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap();
let id = clicks(&mut s, &[(100.0, 100.0), (200.0, 150.0), (120.0, 90.0)]);
let at = anchors(&s, rect);
assert!(near(at[1], vectorcraft_geom::Point::new(200.0, 100.0)) && near(at[2], vectorcraft_geom::Point::new(200.0, 200.0)), "{at:?}");
let a = |p: vectorcraft_geom::Point| json!({"x":p.x,"y":p.y});
let mut curved = vec![a(at[0]), a(at[1]), a(at[2]), a(at[3])];
curved[1]["out"] = json!([240, 110]);
curved[2]["in"] = json!([240, 190]);
run(&mut s, "path.setAnchors", json!({"id":rect,"subpaths":[{"anchors":curved,"closed":true}]}));
let d = recipe(&s, id);
assert!(d.source.is_some(), "still linked");
assert!(near(d.b, vectorcraft_geom::Point::new(230.0, 150.0)), "on the curve: {:?}", d.b);
}
/// The line of a linked dimension of a curved two-anchor path goes to the reflected side when the
/// path is reflected: its handles tell a reflection from a turn.
#[test]
fn a_linked_dimension_of_a_reflected_curve_has_its_line_on_the_reflected_side() {
let mut s = session();
let arch = json!([{"x":100,"y":200,"out":[100,150]},{"x":200,"y":200,"in":[200,150]}]);
let curve = run(&mut s, "path.create", json!({"anchors":arch,"closed":false}))["id"].as_u64().unwrap();
let anchor = |anchor: u64| json!({"node":curve,"subpath":0,"anchor":anchor});
let src = json!({"kind":"points","a":anchor(0),"b":anchor(1)});
// The line 30 pt below the two points (the left normal of a → b points down).
let id = run(&mut s, "dimension.create", json!({"source":src,"offset":30}))["id"].as_u64().unwrap();
assert!((recipe(&s, id).line_ends().0.y - 230.0).abs() < 1e-6);
run(&mut s, "select.set", json!({"ids":[curve]}));
run(&mut s, "object.reflect", json!({"axis":"vertical"}));
let d = recipe(&s, id);
assert!((d.a.y, d.b.y) == (200.0, 200.0) && d.a.x > d.b.x, "{d:?}");
assert!((d.line_ends().0.y - 230.0).abs() < 1e-6, "below the points: {:?}", d.line_ends());
run(&mut s, "object.reflect", json!({"axis":"horizontal"}));
let d = recipe(&s, id);
assert!((d.line_ends().0.y - (d.a.y - 30.0)).abs() < 1e-6, "above the points: {d:?}");
}
/// A point off an arch, whose anchors lie on one line, is held against its anchors and handles:
/// it is reflected with the arch, to the same side as the dimension's line.
#[test]
fn a_point_off_an_arch_is_reflected_with_the_arch() {
let mut s = session();
let arch = json!([{"x":100,"y":200,"out":[100,150]},{"x":200,"y":200,"in":[200,150]}]);
let curve = run(&mut s, "path.create", json!({"anchors":arch,"closed":false}))["id"].as_u64().unwrap();
let id = clicks(&mut s, &[(100.0, 200.0), (150.0, 120.0), (60.0, 120.0)]);
let d = recipe(&s, id);
assert!(d.source.as_ref().is_some_and(|src| src.nodes() == vec![NodeId(curve), NodeId(curve)]), "{:?}", d.source);
assert!(near(d.b, vectorcraft_geom::Point::new(150.0, 120.0)), "{:?}", d.b);
run(&mut s, "select.set", json!({"ids":[curve]}));
run(&mut s, "object.reflect", json!({"axis":"horizontal","origin":[0,200]}));
let d = recipe(&s, id);
assert!(near(d.a, vectorcraft_geom::Point::new(100.0, 200.0)), "{:?}", d.a);
assert!(near(d.b, vectorcraft_geom::Point::new(150.0, 280.0)), "below with the arch: {:?}", d.b);
run(&mut s, "edit.undo", json!({}));
run(&mut s, "select.set", json!({"ids":[curve]}));
run(&mut s, "object.reflect", json!({"axis":"vertical","origin":[150,0]}));
let d = recipe(&s, id);
assert!(near(d.a, vectorcraft_geom::Point::new(200.0, 200.0)), "{:?}", d.a);
assert!(near(d.b, vectorcraft_geom::Point::new(150.0, 120.0)), "above with the arch: {:?}", d.b);
}
/// A point beside a curve that is almost straight is still held against it.
#[test]
fn a_point_beside_an_almost_straight_curve_follows_it() {
let mut s = session();
let flat = json!([{"x":100,"y":200,"out":[100,199.999]},{"x":200,"y":200}]);
let curve = run(&mut s, "path.create", json!({"anchors":flat,"closed":false}))["id"].as_u64().unwrap();
let id = clicks(&mut s, &[(100.0, 200.0), (150.0, 150.0), (60.0, 120.0)]);
let d = recipe(&s, id);
assert!(d.source.as_ref().is_some_and(|src| src.nodes() == vec![NodeId(curve), NodeId(curve)]), "{:?}", d.source);
run(&mut s, "select.set", json!({"ids":[curve]}));
run(&mut s, "object.move", json!({"dx":40,"dy":-30}));
let d = recipe(&s, id);
assert!(near(d.b, vectorcraft_geom::Point::new(190.0, 120.0)), "{:?}", d.b);
}
/// A point off a loop whose two anchors are in one place is held against the loop's handles.
#[test]
fn a_point_off_a_loop_with_its_anchors_in_one_place_follows_it() {
let mut s = session();
let tear = json!([{"x":100,"y":200,"out":[60,120]},{"x":100,"y":200,"in":[140,120]}]);
let curve = run(&mut s, "path.create", json!({"anchors":tear,"closed":false}))["id"].as_u64().unwrap();
let id = clicks(&mut s, &[(100.0, 200.0), (100.0, 60.0), (30.0, 130.0)]);
let d = recipe(&s, id);
assert!(d.source.as_ref().is_some_and(|src| src.nodes() == vec![NodeId(curve), NodeId(curve)]), "{:?}", d.source);
run(&mut s, "select.set", json!({"ids":[curve]}));
run(&mut s, "object.move", json!({"dx":40,"dy":-30}));
let d = recipe(&s, id);
assert!(near(d.b, vectorcraft_geom::Point::new(140.0, 30.0)), "{:?}", d.b);
}
/// Beside a straight two-anchor line, a point is moved by a reflection as a half turn would move
/// it: the line's positions alone do not tell the two apart.
#[test]
fn a_point_beside_a_reflected_line_moves_as_a_half_turn_would() {
let mut s = session();
let line = run(&mut s, "shape.line", json!({"x1":100,"y1":100,"x2":200,"y2":100}))["id"].as_u64().unwrap();
let id = clicks(&mut s, &[(100.0, 100.0), (150.0, 160.0), (60.0, 130.0)]);
run(&mut s, "select.set", json!({"ids":[line]}));
run(&mut s, "object.reflect", json!({"axis":"vertical","origin":[150,0]}));
let d = recipe(&s, id);
assert!(d.source.is_some());
assert!(near(d.a, vectorcraft_geom::Point::new(200.0, 100.0)), "{:?}", d.a);
assert!(near(d.b, vectorcraft_geom::Point::new(150.0, 40.0)), "{:?}", d.b);
}
/// Opening a closed path that a dimension measures leaves the dimension where it was, no longer
/// linked, and undo links it again.
#[test]
fn a_dimension_whose_art_is_opened_stays_where_it_was() {
let mut s = session();
let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap();
let id = clicks(&mut s, &[(100.0, 100.0), (200.0, 150.0), (120.0, 90.0)]);
let before = recipe(&s, id);
let label = text(&s, id);
let a = |p: vectorcraft_geom::Point| json!({"x":p.x,"y":p.y});
let open: Vec<Value> = anchors(&s, rect).into_iter().map(a).collect();
run(&mut s, "path.setAnchors", json!({"id":rect,"subpaths":[{"anchors":open,"closed":false}]}));
let d = recipe(&s, id);
assert!(d.source.is_none(), "no longer linked");
assert_eq!((d.a, d.b, d.offset, text(&s, id)), (before.a, before.b, before.offset, label));
run(&mut s, "edit.undo", json!({}));
assert_eq!(recipe(&s, id).source, before.source, "undo links it again");
}
/// An angle whose arm becomes a curve, and an arc radius whose arc stops being circular, stay where
/// they were, no longer linked.
#[test]
fn angle_and_arc_dimensions_whose_art_no_longer_fits_stay_where_they_were() {
let mut s = session();
let first = edge(&mut s);
let second = run(&mut s, "shape.line", json!({"x1":100,"y1":100,"x2":100,"y2":172}))["id"].as_u64().unwrap();
let angle = json!({"mode":"angular","source":{"kind":"angle","first":{"node":first,"subpath":0,"segment":0},"second":{"node":second,"subpath":0,"segment":0}}});
let id = run(&mut s, "dimension.create", angle)["id"].as_u64().unwrap();
let label = text(&s, id);
let curved = json!([{"x":100,"y":100,"out":[140,130]},{"x":100,"y":172}]);
run(&mut s, "path.setAnchors", json!({"id":second,"subpaths":[{"anchors":curved,"closed":false}]}));
assert_eq!((recipe(&s, id).source, text(&s, id)), (None, label));
let circle = run(&mut s, "shape.ellipse", json!({"x":300,"y":100,"width":144,"height":144}))["id"].as_u64().unwrap();
let arc = json!({"mode":"radius","source":{"kind":"arc","edge":{"node":circle,"subpath":0,"segment":0}},"options":{"units":"in","precision":1}});
let id = run(&mut s, "dimension.create", arc)["id"].as_u64().unwrap();
assert_eq!(text(&s, id), "1.0 in");
run(&mut s, "select.set", json!({"ids":[circle]}));
run(&mut s, "object.scale", json!({"sx":200,"sy":100}));
assert_eq!((recipe(&s, id).source, text(&s, id)), (None, "1.0 in".to_string()));
}
/// Saved and opened again, a dimension to points that are not anchors still follows its art.
#[test]
fn free_points_survive_a_native_file() {
let mut s = session();
let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap();
let id = clicks(&mut s, &[(200.0, 150.0), (330.0, 260.0), (260.0, 330.0)]);
let before = recipe(&s, id);
let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let mut reopened = Session::new();
reopened.add_document(doc, None);
assert_eq!(recipe(&reopened, id).source, before.source);
run(&mut reopened, "select.set", json!({"ids":[rect]}));
run(&mut reopened, "object.move", json!({"dx":40,"dy":-30}));
let d = recipe(&reopened, id);
let v = vectorcraft_geom::Vec2::new(40.0, -30.0);
assert!(near(d.a, before.a + v) && near(d.b, before.b + v), "{before:?} → {d:?}");
}
/// A linked dimension opened from a file with options it cannot be drawn with keeps its art, as a
/// plain group, when the art it measures is deleted.
#[test]
fn a_dimension_that_cannot_be_drawn_again_keeps_its_art() {
let mut s = session();
let rect = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":100}))["id"].as_u64().unwrap();
let id = clicks(&mut s, &[(100.0, 100.0), (200.0, 100.0), (150.0, 80.0)]);
let saved = vectorcraft_format::save(&s.doc().unwrap().doc, false);
let mut v: Value = serde_json::from_slice(&saved).unwrap();
fn bad_precision(v: &mut Value) {
match v {
Value::Object(m) => {
if let Some(Value::Object(o)) = m.get_mut("dimension").and_then(|d| d.get_mut("options")) {
o.insert("precision".into(), json!(99));
}
m.values_mut().for_each(bad_precision);
}
Value::Array(a) => a.iter_mut().for_each(bad_precision),
_ => {}
}
}
bad_precision(&mut v);
let mut reopened = Session::new();
reopened.add_document(vectorcraft_format::load(&serde_json::to_vec(&v).unwrap()).unwrap(), None);
let art = reopened.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap().clone();
run(&mut reopened, "select.set", json!({"ids":[rect]}));
run(&mut reopened, "edit.clear", json!({}));
let n = reopened.doc().unwrap().doc.node(NodeId(id)).cloned().expect("the dimension's art stays");
assert!(n.extras().dimension.is_none(), "no longer a live dimension");
assert_eq!(n.children().unwrap(), &art);
}
/// A linked dimension moved on its own keeps its new place when its art changes later; moved with
/// its art, it keeps its distance from it.
#[test]
@@ -653,7 +1074,8 @@ fn an_expanded_dimension_stays_plain_art() {
assert_eq!((art(&s, id), bounds(&s, id)), (plain, at));
run(&mut s, "edit.clear", json!({}));
assert_eq!((art(&s, id), bounds(&s, id)), (plain, at));
assert!(s.doc().unwrap().doc.node(NodeId(other)).is_none());
assert_eq!(text(&s, other), "20.00 in", "the live dimension stays, no longer linked");
assert!(recipe(&s, other).source.is_none());
// Saved and opened again, it is still plain art.
let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let mut reopened = Session::new();
@@ -975,7 +1397,9 @@ fn dimension_linked_angle_and_circle_update_and_reject_non_circles() {
run(&mut s, "object.scale", json!({"sx":200,"sy":200}));
assert_eq!(text(&s, id), "4 in");
run(&mut s, "object.scale", json!({"sx":200,"sy":100}));
assert!(s.doc().unwrap().doc.node(NodeId(id)).is_none());
// An ellipse has no single radius: the dimension stays where it was, no longer linked.
assert_eq!(text(&s, id), "4 in");
assert!(recipe(&s, id).source.is_none());
assert!(s.execute("dimension.create", &json!({"mode":"radius","source":{"kind":"circle","node":circle}})).is_err());
}
+130 -8
View File
@@ -131,15 +131,48 @@ fn saved_margin(o: &Obj, content: &[Arc<Node>]) -> Option<[f32; 4]> {
envelope_stroke_margin(stroked, plain)
}
/// Envelope `kind` mapping from its contents' bounds with their strokes, for an envelope with
/// Distort Appearance whose contents have strokes it doesn't map from already; `None` otherwise.
/// The box of `content` an envelope of type with Distort Appearance maps from in a file: type by
/// its glyph outlines grown by as far as its strokes paint along a side (half the weight of a
/// centred stroke), without what miter joins add at corners, as are groups by their own strokes;
/// other objects by their bounds with strokes. `None` when nothing in it has bounds.
fn half_stroked_box(content: &[Arc<Node>]) -> Option<Rect> {
/// How far the visible strokes of `appearance` paint along the sides of closed outlines.
fn along(appearance: &Appearance) -> f64 {
appearance.painted_strokes().map(|s| s.side_reach(true)).fold(0.0, f64::max)
}
/// `r` grown by `by` on each side, where that is a distance.
fn grown(r: Rect, by: f64) -> Rect {
if by.is_finite() && by > 0.0 { r.inflate(by, by) } else { r }
}
fn walk(n: &Arc<Node>, acc: Option<Rect>) -> Option<Rect> {
let one = std::slice::from_ref(n);
match &n.kind {
NodeKind::Text(t) => {
let Some((_, ink)) = envelope_content_boxes(one, Affine::IDENTITY) else { return acc };
// The characters' own strokes as the type's bounds with strokes measure them (they
// have no miter allowance there), and the strokes of its appearance along a side.
vectorcraft_geom::union_opt(acc, Some(grown(ink, t.char_stroke_outset().max(along(&n.appearance)))))
}
NodeKind::Group { children, clip: false } => {
let members = children.iter().fold(None, |acc, c| walk(c, acc));
vectorcraft_geom::union_opt(acc, members.map(|b| grown(b, along(&n.appearance))))
}
_ => vectorcraft_geom::union_opt(acc, envelope_content_boxes(one, Affine::IDENTITY).map(|(stroked, _)| stroked)),
}
}
content.iter().fold(None, |acc, c| walk(c, acc))
}
/// Envelope `kind` mapping from its contents' glyphs grown by as far as their strokes paint along a
/// side ([`half_stroked_box`]), for an envelope with Distort Appearance whose contents have strokes it
/// doesn't map from already; `None` otherwise.
fn with_strokes(kind: &NodeKind) -> Option<NodeKind> {
let NodeKind::Envelope { content, options, .. } = kind else { return None };
if !options.distort_appearance {
return None;
}
let (stroked, plain) = envelope_content_boxes(content, Affine::IDENTITY)?;
let margin = envelope_stroke_margin(stroked, plain)?;
let (_, plain) = envelope_content_boxes(content, Affine::IDENTITY)?;
let margin = envelope_stroke_margin(half_stroked_box(content)?, plain)?;
if margin == options.bounds_margin || margin.iter().any(|m| *m < 0.0) || margin.iter().all(|m| *m == 0.0) {
return None;
}
@@ -518,7 +551,8 @@ impl WarpedType {
/// doesn't draw as its drawn art is checked again as a mesh envelope on the grid the file saves
/// for it ([`live_on_saved_grid`]), which opens in its place where it does: its type stays
/// editable in that shape, and the warp's style and bend are lost. With Distort Appearance, each
/// is also checked mapping from its contents' bounds with their strokes ([`with_strokes`]). Each
/// is also checked mapping from its type's glyphs grown by half their strokes' weight
/// ([`with_strokes`]). Each
/// check uses one of the import's checks and, for an envelope that shows, adds to what the page
/// check allows ([`drawn_alike`]). Elsewhere, and where the type is set in a font that isn't
/// available, the drawn art and the hidden type stay.
@@ -581,9 +615,9 @@ impl WarpedType {
// A warp that VectorCraft's own warp doesn't draw as its drawn art may still draw as the
// grid the file saves for it: then it opens as a mesh envelope on that grid.
let on_grid = if is_warp(&obj) { live_on_saved_grid(&obj, before.clone(), inner.clone()) } else { None };
// With Distort Appearance, a file's envelope may map its contents from their bounds with
// their strokes or without them, and for type the size the file saves for that box is often
// out of date (not the size of the type there now): each is checked.
// With Distort Appearance, a file's envelope may map its type from its glyphs grown by half
// their strokes' weight or from the glyphs alone, and for type the size the file saves for
// that box is often out of date (not the size of the type there now): each is checked.
let kinds: Vec<NodeKind> = live(&obj, before, inner)
.into_iter()
.chain(on_grid)
@@ -772,3 +806,91 @@ fn alike_within(live: &Node, drawn: &Node, reach: Option<Rect>, layer: NodeId, s
*spent = Spent { any, solid };
true
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{AppearanceItem, CharStyle, LineJoin, StrokeLayer, TextObject};
/// An envelope of `content` with Distort Appearance, on a flat one-patch mesh.
fn envelope_of(content: Vec<Arc<Node>>) -> NodeKind {
let points = vec![Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(0.0, 50.0), Point::new(100.0, 50.0)];
NodeKind::Envelope {
content,
kind: Box::new(EnvelopeKind::Mesh { rows: 1, cols: 1, points, handles: vec![] }),
fidelity: 50.0,
editing: false,
options: EnvelopeOptions::NEW,
frame: Box::default(),
}
}
/// The bounds margin of the stroked candidate for an envelope of `content`.
fn stroked_margin(content: Vec<Arc<Node>>) -> Option<[f32; 4]> {
match with_strokes(&envelope_of(content)) {
Some(NodeKind::Envelope { options, .. }) => Some(options.bounds_margin),
_ => None,
}
}
/// "AVA" at 40 points, at (10, 60).
fn type_node(id: u64, style: CharStyle) -> Node {
Node::new(NodeId(id), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 60.0), "AVA", CharStyle { size: 40.0, ..style }))))
}
/// A 4 point stroke with miter joins at a limit of 4.
fn mitred() -> StrokeLayer {
let mut stroke = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0);
(stroke.join, stroke.miter_limit) = (LineJoin::Miter, 4.0);
stroke
}
/// Type with Distort Appearance and a stroke from its appearance is tried mapped from its glyphs
/// grown by half the stroke's weight, not from its bounds with strokes, which reach as far as a
/// miter join may at a corner.
#[test]
fn stroked_type_is_tried_mapped_from_its_glyphs_grown_by_half_its_stroke() {
let mut text = type_node(1, CharStyle::default());
let mut appearance = (*text.appearance).clone();
appearance.items.push(AppearanceItem::Stroke(mitred()));
text.appearance = appearance.into();
let content = vec![Arc::new(text)];
let (with, glyphs) = envelope_content_boxes(&content, Affine::IDENTITY).unwrap();
let half = glyphs.inflate(2.0, 2.0);
assert!(with.width() > half.width() + 1.0, "{with:?} {half:?}");
assert_eq!(stroked_margin(content), envelope_stroke_margin(half, glyphs));
}
/// Type stroked by its characters alone is tried mapped from its bounds with strokes, as before,
/// turned and stretched as well as upright.
#[test]
fn type_with_a_character_stroke_is_tried_mapped_from_its_bounds_with_strokes() {
let style = CharStyle { stroke: Paint::solid(Color::BLACK), stroke_width: 4.0, ..CharStyle::default() };
for xf in [
Affine::IDENTITY,
Affine::rotate(std::f64::consts::FRAC_PI_4),
Affine::scale_non_uniform(std::f64::consts::SQRT_2, std::f64::consts::FRAC_1_SQRT_2),
] {
let mut text = type_node(1, style.clone());
if let NodeKind::Text(t) = &mut text.kind {
t.xf = xf * t.xf;
}
let content = vec![Arc::new(text)];
let (with, glyphs) = envelope_content_boxes(&content, Affine::IDENTITY).unwrap();
assert_eq!(stroked_margin(content), envelope_stroke_margin(with, glyphs), "{xf:?}");
}
}
/// A stroke on a group's own appearance round type grows the box by half its weight too.
#[test]
fn a_stroke_on_a_group_of_type_grows_the_box_by_half_its_weight() {
let mut group = Node::new(NodeId(2), NodeKind::Group { children: vec![Arc::new(type_node(1, CharStyle::default()))], clip: false });
let mut appearance = (*group.appearance).clone();
appearance.items.push(AppearanceItem::Stroke(mitred()));
group.appearance = appearance.into();
let content = vec![Arc::new(group)];
let (_, glyphs) = envelope_content_boxes(&content, Affine::IDENTITY).unwrap();
assert_eq!(stroked_margin(content), envelope_stroke_margin(glyphs.inflate(2.0, 2.0), glyphs));
}
}
+2 -2
View File
@@ -472,7 +472,7 @@ fn function_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsRe
Ok(p) => p,
Err(e) => return Err(interp.throw(ErrorType::Syntax, &e.message)),
};
let source = Rc::new(Source { lines: program.lines.clone(), text: units.into() });
let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: interp.source_tag });
let code = match compile_constructed_function(&program, &source) {
Ok(Some(code)) => code,
Ok(None) => return Err(interp.throw(ErrorType::Syntax, "the parameters or body do not form one function")),
@@ -501,7 +501,7 @@ fn function_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) ->
return Ok(Value::String(Str::from_units(units.to_vec())));
}
Some(Func::Native { name, .. }) => format!("function {name}() {{ [native code] }}"),
Some(Func::Host { name, .. } | Func::Data { name, .. }) => format!("function {name}() {{ [native code] }}"),
Some(Func::Host { name, .. } | Func::Data { name, .. } | Func::Eval { name, .. }) => format!("function {name}() {{ [native code] }}"),
Some(Func::Bound { .. }) => "function () { [native code] }".to_string(),
None => return Err(interp.type_error("Function.prototype.toString was called on a value that is not a function")),
};
+2
View File
@@ -171,6 +171,8 @@ pub(crate) struct Handler {
pub(crate) struct Source {
pub(crate) lines: LineTable,
pub(crate) text: Rc<[u16]>,
/// Which source this is, as [`crate::Realm::error_source`] gives it.
pub(crate) tag: u32,
}
#[derive(Debug)]
+3 -1
View File
@@ -176,13 +176,15 @@ pub(crate) enum NativeKind {
/// What a function object runs. `Bound` is a function made by `Function.prototype.bind` (section
/// 15.3.4.5): calling it calls `target` with `this` and `args` before the arguments it is given.
/// `Data` is a host function given [`crate::Data`]; with `text`, each argument arrives as the
/// string `String(value)` gives.
/// string `String(value)` gives. `Eval` is a host function that gives source to run as global code
/// ([`crate::HostItem::Evaluator`]); `end` is called when that code ends.
#[derive(Clone)]
pub(crate) enum Func {
Closure { code: Rc<Code>, env: EnvId },
Native { f: NativeFn, kind: NativeKind, constructor: bool, name: &'static str },
Host { f: Rc<HostFn>, name: Rc<str> },
Data { f: DataFn, name: Rc<str>, text: bool },
Eval { f: DataFn, end: Rc<dyn Fn()>, name: Rc<str> },
Bound { target: ObjId, this: Value, args: Rc<[Value]> },
}
+62 -7
View File
@@ -55,6 +55,8 @@ pub enum HostError {
Type(String),
/// Thrown to the script as a `RangeError`.
Range(String),
/// Thrown to the script as a `SyntaxError`.
Syntax(String),
/// Ends the run with [`LimitKind::Host`] and this message. The script cannot catch it.
Abort(String),
}
@@ -74,6 +76,16 @@ pub enum HostItem {
Getter(HostFunction),
/// A frozen object with these members.
Object(Vec<(String, HostItem)>),
/// A function whose host function gives JavaScript source as a string, which then runs as
/// global code in the realm, as an indirect `eval` runs it, with the run's fuel and limits:
/// the call gives that code's completion value, and its declarations become globals. A result
/// that is not a string is the call's result. `end` is called when the source has run,
/// normally or not. Each source given has the next tag of [`crate::Realm::error_source`],
/// from 1.
Evaluator {
source: HostFunction,
end: Rc<dyn Fn()>,
},
}
enum Step {
@@ -280,6 +292,39 @@ impl Interp {
/// Calls host function `f`: reads its arguments (with `text`, each as the string `String(value)`
/// gives), runs it, and gives its result to the script.
pub(crate) fn call_data(&mut self, f: &crate::heap::DataFn, name: &Rc<str>, args: &[Value], text: bool) -> JsResult<Value> {
let data = self.read_args(name, args, text)?;
match f(data) {
Ok(d) => self.write_data(d, 0, false),
Err(e) => Err(self.host_error(e)?),
}
}
/// Calls evaluator `f` ([`HostItem::Evaluator`]): reads its arguments, runs it, and runs the
/// source it gives as global code, as an indirect `eval` does, giving that code's completion
/// value. A result that is not a string is given to the script as it is. `end` is called once
/// the source has run, normally or not.
pub(crate) fn call_evaluator(&mut self, f: &crate::heap::DataFn, end: &Rc<dyn Fn()>, name: &Rc<str>, args: &[Value]) -> JsResult<Value> {
let data = self.read_args(name, args, false)?;
let source = match f(data) {
Ok(Data::String(source)) => source,
Ok(d) => return self.write_data(d, 0, false),
Err(e) => return Err(self.host_error(e)?),
};
self.evaluated = self.evaluated.saturating_add(1);
let tag = std::mem::replace(&mut self.source_tag, self.evaluated);
let code = self.encode_source(&source).and_then(|units| self.compile_source(&units, false, crate::bytecode::CodeKind::Eval));
self.source_tag = tag;
let r = match code {
Ok(code) => self.run_global(code, true),
Err(e) => Err(self.compile_error_to_abrupt(e)),
};
end();
r
}
/// The arguments of host function `name` as [`Data`] (with `text`, each as the string
/// `String(value)` gives).
fn read_args(&mut self, name: &Rc<str>, args: &[Value], text: bool) -> JsResult<Vec<Data>> {
let mut reading = Reading { limits: self.data_limits, bytes: 0, entries: 0, ancestors: Vec::new(), path: Vec::new(), function: name.clone() };
let mut data = Vec::with_capacity(args.len());
// A conversion can run the script's own `toString`, which may collect garbage.
@@ -302,15 +347,20 @@ impl Interp {
}
self.temp_roots.truncate(mark);
read?;
match f(data) {
Ok(d) => self.write_data(d, 0, false),
Err(HostError::Abort(message)) => {
Ok(data)
}
/// What host error `e` throws, or the limit it ends the run with.
fn host_error(&mut self, e: HostError) -> JsResult<Abrupt> {
match e {
HostError::Abort(message) => {
self.host_abort = Some(message);
Err(Abrupt::Limit(LimitKind::Host))
Ok(Abrupt::Limit(LimitKind::Host))
}
Err(HostError::Error(message)) => Err(self.throw_host(ErrorType::Error, &message)?),
Err(HostError::Type(message)) => Err(self.throw_host(ErrorType::Type, &message)?),
Err(HostError::Range(message)) => Err(self.throw_host(ErrorType::Range, &message)?),
HostError::Error(message) => self.throw_host(ErrorType::Error, &message),
HostError::Type(message) => self.throw_host(ErrorType::Type, &message),
HostError::Range(message) => self.throw_host(ErrorType::Range, &message),
HostError::Syntax(message) => self.throw_host(ErrorType::Syntax, &message),
}
}
@@ -361,6 +411,11 @@ impl Interp {
HostItem::TextFunction(f) => Prop::Data { value: Value::Object(self.host_function(f, &full, true)), flags: ENUMERABLE },
HostItem::Getter(f) => Prop::Accessor { get: Some(self.host_function(f, &full, false)), set: None, flags: ENUMERABLE },
HostItem::Object(items) => Prop::Data { value: Value::Object(self.host_object(&full, items)?), flags: ENUMERABLE },
HostItem::Evaluator { source, end } => {
let proto = self.intrinsics.function_proto;
let f = self.heap.alloc_object(Object::new(Class::Function(Func::Eval { f: source, end, name: full.into() }), Some(proto)));
Prop::Data { value: Value::Object(f), flags: ENUMERABLE }
}
};
self.define(o, key(&name), prop)
}
+11 -1
View File
@@ -185,6 +185,12 @@ pub(crate) struct Interp {
pub(crate) host_abort: Option<String>,
/// How much a value passed to or from a host function may carry.
pub(crate) data_limits: crate::DataLimits,
/// The tag the source compiled next gets ([`crate::Realm::error_source`]).
pub(crate) source_tag: u32,
/// The sources [`crate::HostItem::Evaluator`] functions have given in this realm.
pub(crate) evaluated: u32,
/// The tag of the source the last run's error was raised in.
pub(crate) error_tag: u32,
}
/// The bytes all live [`HeldBytes`] hold, and the bytes dropped holders released that the heap's
@@ -376,6 +382,9 @@ impl Interp {
pause: Rc::default(),
host_abort: None,
data_limits: crate::DataLimits::default(),
source_tag: crate::realm::UNKNOWN_SOURCE,
evaluated: 0,
error_tag: crate::realm::UNKNOWN_SOURCE,
};
crate::builtins::install(&mut interp);
interp.fuel = limits.fuel;
@@ -1851,6 +1860,7 @@ impl Interp {
// keeps itself reachable.
return Ok(Prepared::Done(self.call_data(&f, &name, &args, text)?));
}
Func::Eval { f, end, name } => return Ok(Prepared::Done(self.call_evaluator(&f, &end, &name, &args)?)),
Func::Host { f: host, .. } => {
let js_args: Vec<crate::JsValue> = args.iter().map(|v| self.to_js_value(v)).collect();
return match host(&js_args) {
@@ -2123,7 +2133,7 @@ impl Interp {
let remaining = self.options.max_source_units.saturating_sub(self.source_units.saturating_sub(units.len()));
let options = Options { strict, max_source_units: remaining, ..self.options.clone() };
let program = crate::parse_utf16(units, &options).map_err(CompileError::Syntax)?;
let source = Rc::new(Source { lines: program.lines.clone(), text: units.into() });
let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: self.source_tag });
compile_program(&program, &source, kind).map_err(|e| match e {
CompileFail::Syntax(s) => CompileError::Syntax(s),
CompileFail::Internal(m) => CompileError::Internal(m),
+1 -1
View File
@@ -774,7 +774,7 @@ fn deep_nesting_compiles_on_a_small_stack() {
// ---- the verifier ----
fn code(ops: Vec<Op>) -> Code {
let source = Rc::new(Source { lines: crate::LineTable::new(&[]), text: Rc::from(Vec::<u16>::new()) });
let source = Rc::new(Source { lines: crate::LineTable::new(&[]), text: Rc::from(Vec::<u16>::new()), tag: 0 });
Code {
positions: vec![0; ops.len()],
ops,
+1 -1
View File
@@ -69,7 +69,7 @@ pub use error::{SyntaxError, TOO_DEEP, TOO_LONG};
pub use host::{Data, DataLimits, HostError, HostFunction, HostItem};
pub use lexer::{Goal, Lexer};
pub use number::to_string as number_to_string;
pub use realm::{Interrupt, JsValue, LimitKind, Limits, MAX_UNBROKEN_FUEL, Progress, Realm, RunError, RunErrorKind};
pub use realm::{Interrupt, JsValue, LimitKind, Limits, MAX_UNBROKEN_FUEL, Progress, Realm, RunError, RunErrorKind, UNKNOWN_SOURCE};
pub use token::{Directive, DirectiveName, Keyword, Punct, Span, Token, TokenKind};
/// How a script is read.
+23 -3
View File
@@ -58,6 +58,10 @@ pub enum LimitKind {
Host,
}
/// The tag [`Realm::error_source`] gives for a source that is neither the realm's script nor an
/// evaluator's: code that `eval` or `Function` compiled, or an error with no place.
pub const UNKNOWN_SOURCE: u32 = u32::MAX;
/// The fuel [`Realm::advance`] lets a slice spend, past the slice it was asked for, in a nested run
/// it cannot pause.
pub const MAX_UNBROKEN_FUEL: u64 = 200_000_000;
@@ -218,10 +222,17 @@ impl Realm {
interp.throw_site = None;
interp.last_thrown = Value::Undefined;
interp.host_abort = None;
interp.error_tag = UNKNOWN_SOURCE;
let strict = interp.options.strict;
match interp.encode_source(source).and_then(|units| interp.compile_source(&units, strict, CodeKind::Global)) {
interp.source_tag = 0;
let code = interp.encode_source(source).and_then(|units| interp.compile_source(&units, strict, CodeKind::Global));
interp.source_tag = UNKNOWN_SOURCE;
match code {
Ok(code) => Ok(code),
Err(CompileError::Syntax(e)) => Err(e.into()),
Err(CompileError::Syntax(e)) => {
interp.error_tag = 0;
Err(e.into())
}
Err(CompileError::Internal(m)) => Err(RunError { kind: RunErrorKind::Internal, message: m, line: 0, column: 0 }),
}
}
@@ -229,7 +240,9 @@ impl Realm {
/// The end of a run: its completion value or error, with the interpreter's stacks emptied.
fn outcome(&mut self, result: Result<Value, Abrupt>) -> Result<JsValue, RunError> {
let interp = &mut self.interp;
let (line, column) = interp.throw_site.take().map_or((0, 0), |(code, pc)| code.line_column(pc));
let site = interp.throw_site.take();
interp.error_tag = site.as_ref().map_or(UNKNOWN_SOURCE, |(code, _)| code.source.tag);
let (line, column) = site.map_or((0, 0), |(code, pc)| code.line_column(pc));
interp.stack.clear();
interp.frames.clear();
interp.temp_roots.clear();
@@ -299,6 +312,13 @@ impl Realm {
self.interp.interrupt = interrupt.0.clone();
}
/// Which source the last error's `line` and `column` are in: 0 for the script given to
/// [`Realm::run`] or [`Realm::start`], `n` for the `n`th source a
/// [`crate::HostItem::Evaluator`] gave in this realm, and [`UNKNOWN_SOURCE`] otherwise.
pub fn error_source(&self) -> u32 {
self.interp.error_tag
}
/// Fuel the run has left.
pub fn fuel_left(&self) -> u64 {
self.interp.fuel
+2 -1
View File
@@ -425,7 +425,8 @@ impl Renderer {
if !k.is_finite() || k <= 1e-12 {
return;
}
// The art in its own coordinates, with its own resources.
// The art in its own coordinates, with its own resources (its composite fonts included).
let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&e.doc.composite_fonts);
let symbols = e.doc.symbol_boxes().quick();
let visible = m.inverse().transform_rect_bbox(f.visible);
let inner = Frame { doc: &e.doc, symbols: &symbols, view: f.view * m, visible, px: f.px / k, output: f.output * m, ..*f };
+23
View File
@@ -425,6 +425,29 @@ fn an_instance_is_culled_by_the_art_nested_9_slice_guides_move() {
assert_eq!(img.pixel(95, 50), [255, 0, 0, 255]);
}
#[test]
fn an_instance_that_only_moves_is_culled_by_the_art_its_9_slice_guides_move() {
// A symbol made in the app: art in the 20 × 20 square, a natural size of 2 × 2, and guides a
// quarter in. An instance that only moves draws it at 20 × 20, ten times its natural size. The
// bar from x −9.9 to 0.1 crosses the left guide and is placed by the middle column's map,
// x → 1.9x: moved 115 pt right, it draws from x 96.19 to 115.19 while its art box starts at
// x 105, out of view.
let mut d = Document::new(100.0, 100.0);
let bar = rect_node(&mut d, Rect::new(-9.9, -1.0, 0.1, 1.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Bar".into(), art: Arc::new(bar) });
let frame = rect_node(&mut d, Rect::new(-10.0, -10.0, 10.0, 10.0), Paint::None, Paint::None, 0.0);
let inner = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY });
let art = Node::group(d.alloc_id(), vec![Arc::new(frame), Arc::new(inner)]);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Panel".into(), art: Arc::new(art) });
d.unknown.insert(vectorcraft_doc::SYMBOL_SIZES.into(), serde_json::json!({ "Panel": [2.0, 2.0] }));
d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default()));
let (id, l) = (d.alloc_id(), d.layers[0].id);
let xf = Affine::translate((115.0, 50.0));
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Panel".into(), xf })).unwrap();
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
assert_eq!(img.pixel(98, 50), [255, 0, 0, 255]);
}
#[test]
fn an_instance_with_a_geometry_effect_is_culled_by_its_reshaped_art() {
// The art, (0, 0) to (20, 20), drawn from (-100, 10) is out of view, and the Transform effect
+28
View File
@@ -179,6 +179,34 @@ fn a_documents_own_composite_font_draws_without_being_registered() {
assert_eq!(thumbnail(&composite, id), thumbnail(&plain, plain_id), "thumbnail");
let placed = |d: &Document| crate::placed_document::render_inside(&mut Renderer::new(), d, &d.layers[0], 300, 150, view).map(|r| r.pixels);
assert_eq!(placed(&composite), placed(&plain), "a placed document's art");
// A page that places the document, drawn exactly (no bitmap of the placed art).
let page = |d: &Document, id: NodeId, key: &str| {
use vectorcraft_doc::{ImageBlob, LinkInfo, PlacedDocument};
let blob = ImageBlob::new("application/json", key.as_bytes().to_vec());
let p = PlacedDocument {
link: LinkInfo::new("/art/type.vectorcraft"),
key: key.into(),
bounding: false,
page_box: Default::default(),
width: 300.0,
height: 150.0,
xf: Affine::IDENTITY,
placement: Default::default(),
};
let art = d.node(id).unwrap().clone();
vectorcraft_doc::placed_document::prime(&p, blob.bytes.clone(), (d.clone(), art, Rect::new(0.0, 0.0, 300.0, 150.0)));
let mut page = Document::new(300.0, 150.0);
page.images.insert(key.into(), blob);
let n = Node::new(page.alloc_id(), NodeKind::PlacedDocument(Box::new(p)));
let l = page.layers[0].id;
page.insert(Some(l), 0, n).unwrap();
Renderer::new().render(&page, 300, 150, view, &opts).pixels
};
assert_eq!(
page(&composite, id, "fontchange-composite-placed"),
page(&plain, plain_id, "fontchange-plain-placed"),
"a placed document's art drawn exactly"
);
assert!(db.composite_font(NAME).is_none(), "given back after each render");
}
+99 -18
View File
@@ -93,6 +93,34 @@ fn takes_cell(g: &SGlyph) -> bool {
g.adv > 0.0 || g.face.advance(g.gid) > 0.0
}
/// Vertical type on a path: the turn that takes upright glyph `g` (of size `em`, its glyph space
/// mapped onto the path by `pre`) from its local transform `local` (`unturned` takes its Character
/// Rotation off) to the path, and where the next glyph of its cluster goes in its glyph space.
///
/// It stands in glyph space as it stands in vertical point type's line space: a quarter turn
/// counterclockwise from horizontal path type's pose about the center of its cell, a baseline shift
/// kept across the column and a leading space taken off moving it along the path, then moved across
/// the path as a glyph lying on its side is.
fn upright_path_turn(g: &SGlyph, em: f64, pre: Affine, local: Affine, unturned: Affine, angle: f64, advance: f64) -> (Affine, f64) {
let cell = if takes_cell(g) { upright_cell(g) } else { advance };
let (shift, unshift) = (Affine::translate((0.0, -g.bshift)), Affine::translate((0.0, g.bshift)));
let about = Point::new(cell * 0.5, -upright_centre(g, cell, em));
let mut turn =
Affine::translate((-g.lead, g.face.ideographic_centre() * em)) * shift * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, about) * unshift;
// The effects that keep the glyph's axes fixed (Stair Step) leave its top toward the path's end on
// a path that runs to the left: it turns half a turn about its cell's center.
let tangent = Vec2::new(angle.cos(), angle.sin());
let placed = pre * turn * local * unturned;
if (placed * Point::new(0.0, -1.0) - placed * Point::ZERO).dot(tangent) > 0.0 {
let centre = turn * shift * about;
turn = Affine::translate(centre.to_vec2() * 2.0) * Affine::scale(-1.0) * turn;
}
// Its own advance across, from where the cell centers it: where the shaper set the next glyph of
// its cluster.
let across = g.face.advance(g.gid) * g.sx;
(pre * turn, (cell - across) * 0.5 + across)
}
/// Clear the caret stops of each glyph that shares its cluster with another glyph: only a glyph
/// that alone stands for its characters is a ligature.
fn keep_lone_ligature_carets(glyphs: &mut [PositionedGlyph]) {
@@ -137,6 +165,10 @@ struct Ctx<'a> {
/// 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)>,
/// Vertical type on a path: the tate-chu-yoko block [`Self::emit`] is placing, as (the
/// block's transform from its first glyph's place on the path, the center of its em in the
/// column before that transform), so that the block's other glyphs stand with it on a curve.
tcy_block: Option<(Affine, Point)>,
out: TextLayout,
}
@@ -329,7 +361,7 @@ impl Ctx<'_> {
};
// A glyph whose leading space was taken off (mojikumi) is drawn that much earlier: an
// upright one in vertical type by moving it up the column once it stands upright.
let upright = self.vertical && !self.on_path && g.tcy.is_none() && stands_upright(g);
let upright = self.vertical && g.tcy.is_none() && stands_upright(g) && !(self.on_path && g.inline.is_some());
let lead = if upright { 0.0 } else { g.lead };
let local = match &g.inline {
// Inline graphics: art space to glyph space (no font outline), moved along the line by
@@ -365,28 +397,75 @@ impl Ctx<'_> {
let proportional = g.vpal.filter(|_| g.face.vertical_glyph(g.gid).is_some()).map_or(0.0, |a| a.advance) / g.sy;
(cell + proportional, top)
});
if self.vertical && self.on_path {
// Vertical path type keeps the baseline path and turns each glyph across it.
m = Affine::rotate_about(std::f64::consts::FRAC_PI_2, origin) * m;
if let Some((cell, top)) = path_cell {
// An upright glyph whose top then faces the path's end turns half a turn about the
// center of its cell (without its Character Rotation), so that its top faces the
// path's start and the characters of an upright ligature read along the path in
// order. The effects that keep the glyph's up direction fixed already turn it so on
// a path that runs to the left.
let turned = m * unturned;
let tangent = Vec2::new(angle.cos(), angle.sin());
let along = |y: f64| (turned * Point::new(0.0, y) - origin).dot(tangent);
if along(-top) > along(cell - top) {
let centre = turned * Point::new(g.face.advance(g.gid) * 0.5, cell * 0.5 - top);
m = Affine::translate(centre.to_vec2() * 2.0) * Affine::scale(-1.0) * m;
// Vertical type on a path: the column is centered on the line Align to Path puts the baseline
// on, so the em boxes of upright glyphs, tate-chu-yoko blocks and glyphs lying on their side
// straddle the path. Glyph space here is horizontal path type's: x runs along the path and y
// across it, to the right of the path's direction.
if let (true, true, Some(t)) = (self.vertical, self.on_path, g.tcy) {
// Tate-chu-yoko stands upright as one block: set across the column, centered on its em of
// it and on the path, its top toward the path's start, then moved by
// its Up/Down toward the path's start and by its Left/Right to the path's left, whatever
// the effect does to the glyph's axes. The block's first glyph sets where the block
// stands; the others take its em, its place on the path and its column, so the block
// stays whole on a curve.
let (block, centre) = match self.tcy_block.filter(|_| t.pen > 0.0) {
Some(b) => b,
None => {
// A later glyph without its first glyph: placed back by its pen offset.
let (em, back) =
if t.pen > 0.0 { (t.pen, Affine::translate((-t.pen, 0.0))) } else { (self.style_at(g.byte).size, Affine::IDENTITY) };
let centre = Point::new(em * 0.5, 0.0);
let squeeze =
Affine::translate((centre.x, 0.0)) * Affine::scale_non_uniform(t.squeeze, 1.0) * Affine::translate((-centre.x, 0.0));
let on_path = pre * back;
let mut block = on_path * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, centre) * squeeze;
// The effects that keep the glyph's axes fixed leave the block's top toward the
// path's end on a path that runs to the left: it turns half a turn about its
// center, as an upright glyph does.
let tangent = Vec2::new(angle.cos(), angle.sin());
if (block * Point::new(centre.x, centre.y - 1.0) - block * centre).dot(tangent) > 0.0 {
block = Affine::translate((on_path * centre).to_vec2() * 2.0) * Affine::scale(-1.0) * block;
}
let left = Vec2::new(tangent.y, -tangent.x);
block = Affine::translate(-tangent * t.up + left * t.right) * block;
self.tcy_block = Some((block, centre));
(block, centre)
}
};
let em = centre.x * 2.0;
let across = Affine::translate((centre.x - t.width * 0.5 + t.ink, centre.y + g.face.ideographic_centre() * em));
m = block * across * local;
} else if self.vertical && self.on_path && (g.inline.is_some() || !stands_upright(g)) {
// A glyph lying on its side (and an inline graphic) is a quarter turn clockwise from an
// upright glyph, as horizontal type on the path sets it: its baseline runs along the path
// and its top faces the path's left. It moves across the path so that the center of its
// ideographic em box lies on the path.
// <https://www.w3.org/TR/css-writing-modes-3/#vertical-orientations>
// <https://www.unicode.org/reports/tr50/>
let centre = g.face.ideographic_centre() * g.face.units_per_em() * g.sy;
m = pre * Affine::translate((0.0, centre)) * local;
} else if self.vertical && self.on_path {
// An upright glyph is turned in glyph space ([`upright_path_turn`]), so every effect bends it
// as it bends glyphs lying on their side: its top faces the path's start and the characters
// of an upright ligature read along the path in order.
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, in its glyph space.
m = turn * Affine::translate((after, 0.0)) * local;
self.upright_cluster = Some((g.byte, next + 1, turn, after));
} else {
let (turn, after) = upright_path_turn(g, self.style_at(g.byte).size, pre, local, unturned, angle, advance);
m = turn * local;
self.upright_cluster = (g.len > 0).then_some((g.byte, next + 1, turn, after));
}
} else if let (true, Some(t)) = (self.vertical, g.tcy) {
// Tate-chu-yoko: the block set across the column, centred on its em of it, then moved up
// by its Up/Down and right by its Left/Right (line x runs down the column and line y
// from right to left across it).
let em = self.style_at(g.byte).size;
// from right to left across it). Every glyph of the block takes the em of its first
// glyph, the pen offset of the others.
let em = if t.pen > 0.0 { t.pen } else { self.style_at(g.byte).size };
let start = origin.x - t.pen;
let centre = Point::new(start + em * 0.5, origin.y - g.face.ideographic_centre() * em);
let across = Affine::translate((centre.x - t.width * 0.5 + t.ink - origin.x, 0.0));
@@ -629,6 +708,7 @@ mod emit_kashida_mark_last {
system_layout: false,
fractional_widths: true,
upright_cluster: None,
tcy_block: None,
out: TextLayout::default(),
};
// Last visual glyph of the cluster: a mark that wrongly keeps the mark run (run 1).
@@ -692,6 +772,7 @@ mod emit_kashida_mark_last {
system_layout: false,
fractional_widths: true,
upright_cluster: None,
tcy_block: None,
out: TextLayout::default(),
};
// Simulate the pre-fix path: clone run from the mark glyph only.
+1
View File
@@ -374,6 +374,7 @@ fn layout_runs(db: &FontDb, t: &TextObject, opts: &LayoutOptions, set: Option<&T
system_layout: t.system_layout,
fractional_widths: t.fractional_widths,
upright_cluster: None,
tcy_block: None,
out: TextLayout::default(),
};
// Vertical type: line space turned a quarter turn clockwise (lines become columns, each next
+9
View File
@@ -174,6 +174,15 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject,
let s = style_metrics(cx.face_at(0).as_deref(), cx.style_at(0));
(s.0, s.1)
});
// Vertical type: the caret and the selection span the column, half its em on each side of the
// line Align to Path puts the baseline on.
let m = if cx.vertical {
let half = sg.iter().map(|g| g.face.units_per_em() * g.sx * 0.5).fold(f64::NAN, f64::max);
let half = if half.is_finite() { half } else { cx.style_at(0).size * 0.5 };
(half, half)
} else {
m
};
if ap.is_empty() {
cx.out.overflow = !sg.is_empty();
cx.out.lines.push(LineInfo {
+1
View File
@@ -136,6 +136,7 @@ fn composition_trim_stays_along_the_line_when_a_character_rotates() {
system_layout: false,
fractional_widths: true,
upright_cluster: None,
tcy_block: None,
out: TextLayout::default(),
};
cx.emit(&glyph, pre, origin, angle, 30.0 - lead, 0);
+14 -14
View File
@@ -655,7 +655,7 @@ fn vertical_only_settings_keep_a_horizontal_ligature_whole() {
}
/// On a path, an upright ligature in vertical type stands with its top toward the path's start, and
/// its cell runs from 0.12 em before the pen to 0.88 em after it, with or without the font's vertical
/// its cell runs one em from the pen, as in vertical point type, with or without the font's vertical
/// metrics. The inner stop lies at the middle of that cell, and the first character's selection ends
/// there.
#[test]
@@ -673,21 +673,21 @@ fn an_upright_ligature_on_a_path_takes_its_stop_in_its_turned_cell() {
assert!(l.on_path);
let Some(g) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) };
assert_eq!(g.carets, vec![(1, 0.5)], "{family}");
assert!((g.width - 100.0).abs() < 1e-9 && (g.width_start + 12.0).abs() < 1e-9, "{family}: {} {}", g.width, g.width_start);
assert!((g.width - 100.0).abs() < 1e-9 && g.width_start.abs() < 1e-9, "{family}: {} {}", g.width, g.width_start);
let x = |b| caret_position(&l, b).0.x - g.origin.x;
assert!((x(1) - 38.0).abs() < 1e-6, "{family}: {}", x(1));
assert!((x(1) - 50.0).abs() < 1e-6, "{family}: {}", x(1));
let q = selection_quads(&l, 0, 1);
assert_eq!(q.len(), 1, "{q:?}");
assert!(((q[0][1] - q[0][0]).hypot() - 38.0).abs() < 1e-6, "{family}: {q:?}");
// A baseline shift moves the turned glyph along the path, and its stop with it.
let ink = g.outline.bounding_box().x0;
assert!(((q[0][1] - q[0][0]).hypot() - 50.0).abs() < 1e-6, "{family}: {q:?}");
// A baseline shift moves the turned glyph across the path, and leaves its stop in place.
let ink = g.outline.bounding_box();
t.runs[0].style.baseline_shift = 10.0;
let l = layout(&db, &t);
let Some(s) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) };
let moved = s.outline.bounding_box().x0 - ink;
assert!(moved.abs() > 1.0, "{family}: the shift moves the glyph along the path: {moved}");
let moved = s.outline.bounding_box().center() - ink.center();
assert!((moved.x.abs()) < 1e-6 && (moved.y + 10.0).abs() < 1e-6, "{family}: the shift moves the glyph to the path's left: {moved:?}");
let stop = caret_position(&l, 1).0.x - s.origin.x;
assert!((stop - 38.0 - moved).abs() < 1e-6, "{family}: {stop} against {}", 38.0 + moved);
assert!((stop - 50.0).abs() < 1e-6, "{family}: {stop}");
// Horizontal Scale widens the glyph across the path; its stop stays inside the glyph.
t.runs[0].style.baseline_shift = 0.0;
t.runs[0].style.h_scale = 200.0;
@@ -718,7 +718,7 @@ fn an_upright_ligature_on_a_path_takes_its_stop_in_its_turned_cell() {
/// On a path, an upright ligature in vertical type reads along the path in order: the top of its
/// cell comes first, and its font caret (600 units down the cell) is drawn where the caret, a click
/// and the selection put the stop, on a path running either way with any path effect. A glyph lying
/// on its side keeps its top toward the path's end.
/// on its side is a quarter turn clockwise from that, as horizontal type on the path sets it.
#[test]
fn an_upright_ligature_on_a_path_reads_along_it_in_order() {
use vectorcraft_doc::PathEffect;
@@ -763,8 +763,8 @@ fn an_upright_ligature_on_a_path_reads_along_it_in_order() {
let (q0, q1) = (along(q[0][0]), along(q[0][1]));
assert!((q0.min(q1) - drawn.min(0.0)).abs() < 1e-6 && (q0.max(q1) - drawn.max(0.0)).abs() < 1e-6, "{to} {effect:?}: {q:?} against {drawn}");
}
// Without Standard Vertical Roman Alignment the letter lies on its side: its top still faces
// the path's end and its advance runs across the path, to its right.
// Without Standard Vertical Roman Alignment the letter lies on its side: its top faces the
// path's left and its advance runs along the path.
let mut line = BezPath::new();
line.move_to((0.0, 0.0));
line.line_to((1000.0, 0.0));
@@ -774,9 +774,9 @@ fn an_upright_ligature_on_a_path_reads_along_it_in_order() {
let l = layout(&db, &t);
let Some(s) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) };
let tangent = Vec2::new(s.angle.cos(), s.angle.sin());
let right = Vec2::new(-tangent.y, tangent.x);
let left = Vec2::new(tangent.y, -tangent.x);
let (pen, up, ahead) = (s.xf * Point::ZERO, s.xf * Point::new(0.0, -500.0), s.xf * Point::new(500.0, 0.0));
assert!((up - pen).dot(tangent) > 1.0 && (ahead - pen).dot(right) > 1.0, "{pen:?} {up:?} {ahead:?}");
assert!((up - pen).dot(left) > 1.0 && (ahead - pen).dot(tangent) > 1.0, "{pen:?} {up:?} {ahead:?}");
}
/// On a path, an upright ligature whose cell Proportional Metrics shortens (`vpal`, from 1000 units
+298
View File
@@ -184,6 +184,304 @@ fn type_on_a_path_stays_horizontal() {
assert!(!layout(FontDb::global(), &t).vertical);
}
/// `text` in vertical type on a straight path from `from` to `to`, with path effect `effect`.
fn on_vertical_path(text: &str, from: (f64, f64), to: (f64, f64), effect: vectorcraft_doc::PathEffect) -> TextObject {
let mut t = vertical(text);
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new(from, to).to_path(0.1)), start: 0.0, end: None };
t.path_effect = effect;
t
}
/// Glyph `i`'s top (its ascender direction) and its own advance direction in text space, as unit
/// vectors.
fn top_and_ahead(l: &TextLayout, i: usize) -> (Vec2, Vec2) {
let g = &l.glyphs[i];
let pen = g.xf * Point::ZERO;
let unit = |v: Vec2| v / v.hypot();
(unit(g.xf * Point::new(0.0, -500.0) - pen), unit(g.xf * Point::new(500.0, 0.0) - pen))
}
/// On a vertical path with Rainbow, an upright glyph's top faces the path's start, and a letter lying
/// on its side is a quarter turn clockwise from it: its top faces the path's left and its baseline
/// runs along the path. Both turn with the path when it runs the other way. The effects that keep
/// the glyphs' axes fixed on the page keep that relation only on a path that runs to the right.
#[test]
fn on_a_vertical_path_letters_lie_a_quarter_turn_clockwise_from_upright_glyphs() {
use vectorcraft_doc::PathEffect;
for to in [(0.0, 1000.0), (0.0, -1000.0), (1000.0, 0.0), (-1000.0, 0.0), (700.0, 700.0)] {
let l = layout(FontDb::global(), &on_vertical_path("§L", (0.0, 0.0), to, PathEffect::Rainbow));
let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot();
let left = Vec2::new(tangent.y, -tangent.x);
let (upright, _) = top_and_ahead(&l, 0);
let (side, ahead) = top_and_ahead(&l, 1);
assert!((upright + tangent).hypot() < 1e-6, "{to:?}: the upright top faces the start: {upright:?}");
// A clockwise quarter turn with y down takes (x, y) to (-y, x).
assert!((side - Vec2::new(-upright.y, upright.x)).hypot() < 1e-6, "{to:?}: {side:?} against {upright:?}");
assert!((side - left).hypot() < 1e-6 && (ahead - tangent).hypot() < 1e-6, "{to:?}: {side:?} {ahead:?}");
}
// Skew, 3D Ribbon and Stair Step keep axes of the glyphs fixed on the page: on a path that runs
// to the right the relation holds. On one that runs to the left, Skew and 3D Ribbon mirror both
// kinds, and Stair Step keeps letters level while upright glyphs still face the path's start, so
// letters are a quarter turn counterclockwise from upright glyphs there.
for effect in [PathEffect::Skew, PathEffect::Ribbon3d, PathEffect::StairStep] {
for to in [(1000.0, 0.0), (700.0, 700.0), (0.0, 1000.0), (0.0, -1000.0), (-1000.0, 0.0), (-700.0, 300.0)] {
// Skew and 3D Ribbon slant glyphs on a slanted path, where no turn relates them exactly.
if to.1 != 0.0 && effect != PathEffect::StairStep {
continue;
}
let l = layout(FontDb::global(), &on_vertical_path("§L", (0.0, 0.0), to, effect));
let (upright, _) = top_and_ahead(&l, 0);
let (side, _) = top_and_ahead(&l, 1);
let clockwise = to.0 >= 0.0;
let want = if clockwise { Vec2::new(-upright.y, upright.x) } else { Vec2::new(upright.y, -upright.x) };
assert!((side - want).hypot() < 1e-6, "{effect:?} {to:?}: {side:?} against {upright:?}");
}
}
}
/// On a vertical path, letters lying on their side are set as horizontal type on the same path sets
/// them, with every path effect and on a path running either way, and they follow one another along
/// the path without overlapping where the effect keeps them square to it.
#[test]
fn on_a_vertical_path_letters_keep_the_horizontal_path_pose() {
use vectorcraft_doc::PathEffect;
for effect in [PathEffect::Rainbow, PathEffect::Skew, PathEffect::Ribbon3d, PathEffect::StairStep, PathEffect::Gravity] {
for to in [(1000.0, 0.0), (-1000.0, 0.0), (700.0, 300.0)] {
let mut vertical = on_vertical_path("LLl", (0.0, 0.0), to, effect);
let v = layout(FontDb::global(), &vertical);
vertical.vertical = false;
let h = layout(FontDb::global(), &vertical);
for i in 0..3 {
let (a, b) = (v.glyphs[i].xf.as_coeffs(), h.glyphs[i].xf.as_coeffs());
assert!(a.iter().zip(b).take(4).all(|(a, b)| (a - b).abs() < 1e-9), "{effect:?} {to:?} glyph {i}: {a:?} against {b:?}");
}
let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot();
let span = |i: usize| {
let along = v.glyphs[i].outline.elements().iter().filter_map(|el| el.end_point()).map(|p| p.to_vec2().dot(tangent));
along.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), d| (lo.min(d), hi.max(d)))
};
// Skew slants the letters, Stair Step keeps them level and Gravity fans them toward the
// path's center, so their ink can reach past the next letter's.
if matches!(effect, PathEffect::Rainbow | PathEffect::Ribbon3d) {
assert!((0..2).all(|i| span(i).1 <= span(i + 1).0 + 1e-6), "{effect:?} {to:?}: {:?}", (0..3).map(span).collect::<Vec<_>>());
}
}
}
}
/// On a vertical path, the column is centered on the path: the em boxes of upright glyphs, of letters
/// lying on their side and of a tate-chu-yoko block straddle it, their centers on the path line, on
/// a path running any way.
#[test]
fn on_a_vertical_path_the_column_is_centered_on_the_path() {
use vectorcraft_doc::PathEffect;
let Some(face) = FontDb::global().face("Source Sans 3", "Regular") else { panic!("the bundled face") };
let (em, centre) = (face.units_per_em(), face.ideographic_centre());
for to in [(0.0, 1000.0), (0.0, -1000.0), (1000.0, 0.0), (-700.0, 700.0)] {
let l = layout(FontDb::global(), &on_vertical_path("§H12§", (0.0, 0.0), to, PathEffect::Rainbow));
let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot();
let across = |p: Point| p.to_vec2().dot(Vec2::new(tangent.y, -tangent.x));
// An upright glyph (the shaper centers a narrow one in its em box, as off a path) has its
// ink centered on the path.
for i in [0, 4] {
let mid = across(ink(&l, i).center());
assert!(mid.abs() < 1.0, "{to:?}: upright glyph {i} is {mid} off the path");
}
// A letter lying on its side: its em box's center is `centre` em above its baseline.
let mid = across(l.glyphs[1].xf * Point::new(0.0, -centre * em));
assert!(mid.abs() < 1e-6, "{to:?}: the letter's em box is {mid} off the path");
// The digits of the block stand side by side across the path, the block centered on it.
let block = (across(ink(&l, 2).center()) + across(ink(&l, 3).center())) * 0.5;
assert!(block.abs() < 1.5, "{to:?}: the block is {block} off the path");
}
}
/// On a vertical path, the caret and the selection span the column, half its em on each side of the
/// path, as the glyphs do.
#[test]
fn on_a_vertical_path_the_caret_and_selection_span_the_column() {
use vectorcraft_doc::PathEffect;
for to in [(0.0, 1000.0), (1000.0, 0.0), (-700.0, 700.0)] {
let l = layout(FontDb::global(), &on_vertical_path("§L§", (0.0, 0.0), to, PathEffect::Rainbow));
let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot();
let across = |p: Point| p.to_vec2().dot(Vec2::new(tangent.y, -tangent.x));
let (top, bottom) = caret_position(&l, "§".len());
assert!((across(top) - 10.0).abs() < 1e-6 && (across(bottom) + 10.0).abs() < 1e-6, "{to:?}: caret {top:?} {bottom:?}");
for q in selection_quads(&l, 0, "§L§".len()) {
let (lo, hi) = q.iter().map(|p| across(*p)).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), a| (lo.min(a), hi.max(a)));
assert!((lo + 10.0).abs() < 1e-6 && (hi - 10.0).abs() < 1e-6, "{to:?}: highlight from {lo} to {hi}");
}
}
}
/// On a straight path running down from the anchor, vertical type sets every glyph where vertical
/// point type sets it: upright glyphs with a horizontal scale stay centered on the column, a baseline
/// shift moves an upright glyph across the column, every upright glyph keeps its own cell along the
/// path, and a combining mark stays on its upright letter.
#[test]
fn on_a_downward_vertical_path_glyphs_sit_where_vertical_point_type_sets_them() {
use crate::test_fonts::{VERTICAL_FAMILY, vertical_font};
let run = |text: &str, style: CharStyle| TextRun { text: text.into(), style, inline: None };
let runs = |plain: CharStyle| {
vec![
run("§×", plain.clone()),
run("§", CharStyle { h_scale: 150.0, ..plain.clone() }),
run("§", CharStyle { baseline_shift: 3.0, ..plain.clone() }),
run("§", CharStyle { rotation: 30.0, ..plain.clone() }),
run("L", CharStyle { h_scale: 150.0, ..plain.clone() }),
run("L", CharStyle { baseline_shift: 3.0, ..plain.clone() }),
run("q\u{301}x", CharStyle { standard_vertical_roman: true, ..plain.clone() }),
run("§", plain),
]
};
let compare = |db: &FontDb, point: &TextObject, name: &str| {
let mut on_path = point.clone();
on_path.kind =
TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (0.0, 3000.0)).to_path(0.1)), start: 0.0, end: None };
let (p, v) = (layout(db, point), layout(db, &on_path));
assert_eq!(p.glyphs.len(), v.glyphs.len(), "{name}");
let apart: Vec<_> = (p.glyphs.iter().zip(&v.glyphs).enumerate())
.filter(|(_, (a, b))| a.xf.as_coeffs().iter().zip(b.xf.as_coeffs()).any(|(a, b)| (a - b).abs() > 1e-6))
.map(|(i, (a, b))| format!("glyph {i}: {:?} on the path, {:?} in point type", b.xf.as_coeffs(), a.xf.as_coeffs()))
.collect();
assert!(apart.is_empty(), "{name}: {apart:#?}");
};
let mut point = vertical("");
point.runs = runs(CharStyle { size: 20.0, ..CharStyle::default() });
compare(FontDb::global(), &point, "the bundled face");
// A face with vertical metrics, whose cells hang from their vertical origins.
let db = FontDb::with_font_dirs(vec![]);
db.add_font(vertical_font(true).unwrap());
point.runs = runs(CharStyle { size: 20.0, font_family: VERTICAL_FAMILY.into(), ..CharStyle::default() });
compare(&db, &point, "vertical metrics");
// The horizontally scaled upright glyph is wider across the column, centered where the plain one is.
point.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (0.0, 3000.0)).to_path(0.1)), start: 0.0, end: None };
let l = layout(&db, &point);
let (plain, wide) = (ink(&l, 0), ink(&l, 2));
assert!(wide.width() > plain.width() * 1.4 && (wide.center().x - plain.center().x).abs() < 1e-6, "{plain:?} {wide:?}");
}
/// With Skew on a curved vertical path, every glyph's axis along the path follows the tangent and
/// its axis across the path stays upright on the page: an upright glyph's top runs back along the
/// tangent and its own x axis points up the page, and a letter lying on its side keeps its top up
/// the page and its baseline along the tangent.
#[test]
fn on_a_curved_vertical_path_skew_keeps_the_axis_across_the_path_upright() {
use vectorcraft_doc::PathEffect;
let mut arc = kurbo::BezPath::new();
arc.move_to((0.0, 0.0));
arc.curve_to((40.0, 0.0), (80.0, 40.0), (80.0, 120.0));
let mut t = vertical("§L§L§");
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&arc), start: 0.0, end: None };
t.path_effect = PathEffect::Skew;
let l = layout(FontDb::global(), &t);
let page_up = Vec2::new(0.0, -1.0);
for (i, g) in l.glyphs.iter().enumerate() {
let tangent = Vec2::new(g.angle.cos(), g.angle.sin());
let (top, ahead) = top_and_ahead(&l, i);
if i % 2 == 0 {
assert!((top + tangent).hypot() < 1e-6 && (ahead - page_up).hypot() < 1e-6, "upright glyph {i}: {top:?} {ahead:?} at {tangent:?}");
} else {
assert!((top - page_up).hypot() < 1e-6 && (ahead - tangent).hypot() < 1e-6, "letter {i}: {top:?} {ahead:?} at {tangent:?}");
}
}
let turns = l.glyphs.iter().map(|g| g.angle).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), a| (lo.min(a), hi.max(a)));
assert!(turns.1 - turns.0 > 0.3, "the glyphs sit on the curve: {turns:?}");
}
/// A tate-chu-yoko block whose characters differ in size stays one block: every character stands on
/// the block's baseline in the em of its first character, in vertical type and on a vertical path.
#[test]
fn a_tate_chu_yoko_block_of_mixed_sizes_shares_its_first_characters_em() {
let run = |text: &str, size: f64| TextRun { text: text.into(), style: CharStyle { size, ..CharStyle::default() }, inline: None };
let mut t = vertical("");
t.runs = vec![run("§", 20.0), run("1", 20.0), run("2", 10.0), run("§", 20.0)];
let mut on_path = t.clone();
on_path.kind =
TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (0.0, 1000.0)).to_path(0.1)), start: 0.0, end: None };
for (name, t) in [("vertical", &t), ("on a path", &on_path)] {
let l = layout(FontDb::global(), t);
assert!(l.glyphs[2].advance == 0.0, "{name}: 12 is one block: {:?}", l.glyphs.iter().map(|g| g.advance).collect::<Vec<_>>());
let (one, two) = (ink(&l, 1), ink(&l, 2));
assert!((one.y1 - two.y1).abs() < 0.5 && two.center().x > one.center().x, "{name}: on one baseline: {one:?} {two:?}");
}
}
/// On a vertical path, Tate-chu-yoko Settings Up/Down move a marked block toward the path's start
/// and Left/Right move it to the right of its upright characters (the path's left), with every
/// effect on a path running any way.
#[test]
fn on_a_vertical_path_tate_chu_yoko_settings_keep_their_directions() {
use vectorcraft_doc::PathEffect;
for effect in [PathEffect::Rainbow, PathEffect::Skew, PathEffect::Ribbon3d, PathEffect::StairStep, PathEffect::Gravity] {
for to in [(1000.0, 0.0), (-1000.0, 0.0), (0.0, 1000.0), (-700.0, -700.0)] {
let at = |up: f64, right: f64| {
let marked = CharStyle { size: 20.0, tate_chu_yoko: true, tcy_up_down: up, tcy_left_right: right, ..CharStyle::default() };
let mut t = on_vertical_path("", (0.0, 0.0), to, effect);
t.runs = vec![
TextRun { text: "§".into(), style: CharStyle { size: 20.0, ..CharStyle::default() }, inline: None },
TextRun { text: "12".into(), style: marked, inline: None },
];
ink(&layout(FontDb::global(), &t), 1).center().to_vec2()
};
let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot();
let left = Vec2::new(tangent.y, -tangent.x);
let (plain, up, right) = (at(0.0, 0.0), at(4.0, 0.0), at(0.0, 3.0));
assert!((up - plain + tangent * 4.0).hypot() < 1e-6, "{effect:?} {to:?}: Up/Down moved it {:?}", up - plain);
assert!((right - plain - left * 3.0).hypot() < 1e-6, "{effect:?} {to:?}: Left/Right moved it {:?}", right - plain);
}
}
}
/// On a vertical path, a tate-chu-yoko block stands upright like the glyphs around it: its digits
/// read across the column, their tops toward the path's start, centered on the column.
#[test]
fn on_a_vertical_path_tate_chu_yoko_stands_upright() {
use vectorcraft_doc::PathEffect;
for to in [(0.0, 1000.0), (0.0, -1000.0), (1000.0, 0.0)] {
let l = layout(FontDb::global(), &on_vertical_path("§12§", (0.0, 0.0), to, PathEffect::Rainbow));
assert!(l.glyphs[1].outline.elements().len() > 1, "{to:?}: the 1 draws");
let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot();
let left = Vec2::new(tangent.y, -tangent.x);
let (upright, _) = top_and_ahead(&l, 0);
for i in [1, 2] {
let (top, ahead) = top_and_ahead(&l, i);
assert!((top - upright).hypot() < 1e-6 && (ahead - left).hypot() < 1e-6, "{to:?} digit {i}: {top:?} {ahead:?}");
}
let centre = |i: usize| ink(&l, i).center().to_vec2();
let (one, two) = (centre(1), centre(2));
assert!((two - one).dot(left) > 3.0 && (two - one).dot(tangent).abs() < 1.0, "{to:?}: 12 side by side: {one:?} {two:?}");
let across = |v: Vec2| v.dot(left);
let block = (across(one) + across(two)) * 0.5;
assert!((block - across(centre(0))).abs() < 2.0, "{to:?}: the block is centered on the column: {block} against {}", across(centre(0)));
let (before, after) = ((centre(0)).dot(tangent), (centre(3)).dot(tangent));
assert!(before < one.dot(tangent) && one.dot(tangent) < after, "{to:?}: the block stands between the marks");
}
// On a curve the block stays whole: both digits turn alike and stand on one baseline.
let mut arc = kurbo::BezPath::new();
arc.move_to((0.0, 0.0));
arc.curve_to((0.0, 40.0), (20.0, 60.0), (60.0, 60.0));
let mut t = vertical("§12§");
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&arc), start: 0.0, end: None };
let l = layout(FontDb::global(), &t);
let (a, b) = (l.glyphs[1].xf.as_coeffs(), l.glyphs[2].xf.as_coeffs());
assert!(a.iter().zip(b).take(4).all(|(a, b)| (a - b).abs() < 1e-9), "{a:?} against {b:?}");
let (top, ahead) = top_and_ahead(&l, 1);
let (p1, p2) = (l.glyphs[1].xf * Point::ZERO, l.glyphs[2].xf * Point::ZERO);
assert!((p2 - p1).dot(top).abs() < 1e-6 && (p2 - p1).dot(ahead) > 3.0, "{p1:?} {p2:?}");
// With every effect, on a path running either way, the block's top faces the path's start.
for effect in [PathEffect::Rainbow, PathEffect::Skew, PathEffect::Ribbon3d, PathEffect::StairStep, PathEffect::Gravity] {
for to in [(1000.0, 0.0), (-1000.0, 0.0)] {
let l = layout(FontDb::global(), &on_vertical_path("§12§", (0.0, 0.0), to, effect));
let tangent = Vec2::new(to.0, to.1) / Vec2::new(to.0, to.1).hypot();
for i in [1, 2] {
let (top, _) = top_and_ahead(&l, i);
assert!(top.dot(tangent) < -0.5, "{effect:?} {to:?} digit {i}: {top:?}");
}
}
}
}
#[test]
fn three_digits_stand_across_the_column_squeezed_into_one_em() {
let l = layout(FontDb::global(), &vertical("§100§"));
+88 -4
View File
@@ -5,8 +5,8 @@
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
use serde_json::{Value, json};
use vectorcraft_doc::{
Node,
dimension::{self, Dimension, DimensionMode, DimensionOptions, DimensionSource, Orientation, PointRef, SegmentRef},
Node, NodeId,
dimension::{self, Dimension, DimensionMode, DimensionOptions, DimensionSource, FreePoint, Orientation, PointRef, SegmentRef},
};
use vectorcraft_geom::{BezPath, Point, Vec2};
@@ -213,6 +213,30 @@ fn nearest_on(cx: &ToolContext, edge: &SegmentRef, p: Point) -> Option<Point> {
(q.x.is_finite() && q.y.is_finite()).then_some(q)
}
/// What a dimension between two picked points links to. A point on an anchor links to the anchor.
/// A point on a segment keeps its place along the segment. Any other point is held
/// against the anchors of the path under it, or, with no path under it, the path of the other
/// point, so that it moves with that path. None when neither point has a path to link to.
fn points_source(cx: &ToolContext, (a, ra): (Point, &Option<PointRef>), (b, rb): (Point, &Option<PointRef>)) -> Option<DimensionSource> {
let (edge_a, edge_b) = (pick(cx, a), pick(cx, b));
let node = |r: &Option<PointRef>, e: &Option<SegmentRef>| r.as_ref().map(|r| r.node).or_else(|| e.as_ref().map(|e| e.node));
let (path_a, path_b) = (node(ra, &edge_a), node(rb, &edge_b));
let free = |p: Point, r: &Option<PointRef>, edge: &Option<SegmentRef>, own: Option<NodeId>, other: Option<NodeId>| {
if r.is_some() {
return None;
}
edge.as_ref().and_then(|e| FreePoint::on_segment(cx.doc, e, p)).or_else(|| own.or(other).and_then(|node| FreePoint::on(cx.doc, node, p)))
};
let (free_a, free_b) = (free(a, ra, &edge_a, path_a, path_b), free(b, rb, &edge_b, path_b, path_a));
(ra.is_some() || rb.is_some() || free_a.is_some() || free_b.is_some()).then(|| DimensionSource::Points {
a: ra.clone(),
b: rb.clone(),
vertex: None,
free_a,
free_b,
})
}
impl DimensionTool {
/// Where a Linear pick at `p` lands: on the nearest anchor of any path, within reach (kept as
/// a link to its path); else where Smart Guides put it (another object's anchor or centre, in
@@ -364,7 +388,7 @@ impl DimensionTool {
}
self.points.push((at, reference));
if let [(a, ra), (b, rb)] = self.points.as_slice() {
let source = (ra.is_some() || rb.is_some()).then(|| DimensionSource::Points { a: ra.clone(), b: rb.clone(), vertex: None });
let source = points_source(cx, (*a, ra), (*b, rb));
let mut d = self.recipe(DimensionMode::Linear, *a, *b, Point::ZERO, source);
if d.resolve(cx.doc).is_ok() {
self.second = Some((*b, p));
@@ -667,7 +691,7 @@ mod tests {
let d = t.pending.as_ref().expect("both anchors were picked");
assert_eq!((d.a, d.b), (Point::new(100.0, 100.0), Point::new(200.0, 200.0)));
let anchor = |anchor| Some(PointRef { node: id, subpath: 0, anchor });
assert_eq!(d.source, Some(DimensionSource::Points { a: anchor(0), b: anchor(2), vertex: None }));
assert_eq!(d.source, Some(DimensionSource::Points { a: anchor(0), b: anchor(2), vertex: None, free_a: None, free_b: None }));
// Dragged past the anchor's reach, the press drags out the dimension of a segment there.
let mut t = DimensionTool::default();
t.pointer(&c, &ev(PointerKind::Down, 100.0, 100.0));
@@ -677,6 +701,66 @@ mod tests {
assert!(matches!(t.pointer(&c, &ev(PointerKind::Up, 100.0, 60.0)).last(), Some(Action::Commit)));
}
/// A point picked on a segment, not on an anchor, keeps its place along the segment. A point
/// off the art is held against the anchors of the other point's path. Both then follow the
/// path, as the anchor at the other end does.
#[test]
fn points_off_anchors_are_held_against_the_path() {
use vectorcraft_doc::dimension::FreePoint;
let (d, id) = doc_with_rect();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let click = |t: &mut DimensionTool, x: f64, y: f64| {
t.pointer(&c, &PointerEvent::new(PointerKind::Down, x, y));
t.pointer(&c, &PointerEvent::new(PointerKind::Up, x, y));
};
let mut t = DimensionTool::default();
click(&mut t, 100.0, 100.0);
click(&mut t, 200.0, 150.0);
let pending = t.pending.as_ref().expect("both points were picked");
let anchor = Some(PointRef { node: id, subpath: 0, anchor: 0 });
let Some(DimensionSource::Points { a, b: None, vertex: None, free_a: None, free_b: Some(FreePoint::OnSegment { edge, t }) }) =
&pending.source
else {
panic!("{:?}", pending.source)
};
assert_eq!((a, edge), (&anchor, &SegmentRef { node: id, subpath: 0, segment: 1 }));
assert!((t - 0.5).abs() < 1e-9, "{t}");
let mut t = DimensionTool::default();
click(&mut t, 400.0, 400.0);
click(&mut t, 200.0, 200.0);
let pending = t.pending.as_ref().expect("both points were picked");
let Some(DimensionSource::Points { free_a: Some(free), b: Some(b), .. }) = &pending.source else { panic!("{:?}", pending.source) };
let FreePoint::Anchors { node, anchors, across } = free else { panic!("{free:?}") };
assert_eq!((*node, anchors.len(), *across, b.node, b.anchor), (id, 3, 0.0, id, 2));
let at = free.position(&d).expect("the anchors that hold it exist");
assert!(at.distance(pending.a) < 1e-9, "{at:?} {:?}", pending.a);
}
/// A point that Smart Guides put in line with the path's center is held, where the guide put
/// it, against the anchors of the path at the other end.
#[test]
fn a_point_smart_guides_place_is_held_against_the_path() {
use vectorcraft_doc::dimension::FreePoint;
let (d, id) = doc_with_rect();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
assert!(c.smart_guides);
let mut t = DimensionTool::default();
for (x, y) in [(200.0, 200.0), (151.5, 330.0)] {
t.pointer(&c, &PointerEvent::new(PointerKind::Move, x, y));
t.pointer(&c, &PointerEvent::new(PointerKind::Down, x, y));
t.pointer(&c, &PointerEvent::new(PointerKind::Up, x, y));
}
let pending = t.pending.as_ref().expect("both points were picked");
assert!((pending.b.x - 150.0).abs() < 1e-9, "in line with the center: {:?}", pending.b);
let Some(DimensionSource::Points { a: Some(a), free_b: Some(free), .. }) = &pending.source else { panic!("{:?}", pending.source) };
assert!(matches!(free, FreePoint::Anchors { node, .. } if *node == id), "{free:?}");
assert_eq!(a.node, id);
let at = free.position(&d).expect("the anchors that hold it exist");
assert!(at.distance(pending.b) < 1e-9, "{at:?} {:?}", pending.b);
}
/// The anchors of a live rectangle are picked the same way.
#[test]
fn a_press_on_a_live_shapes_anchor_picks_it() {
+2 -5
View File
@@ -402,11 +402,8 @@ impl ReshapeTool {
/// `path.reshape`'s params for `press` dragged by `d`.
fn params(cx: &ToolContext, press: &ReshapePress, d: Vec2) -> Value {
let others: Vec<[usize; 2]> = press.others.iter().map(|&((si, ai), _)| [si, ai]).collect();
let mut v = json!({ "id": press.id.0, "x": press.at.x, "y": press.at.y, "dx": d.x, "dy": d.y, "tol": cx.tol(4.0) });
if !others.is_empty() {
v["focal"] = json!(others);
}
v
// `focal` is sent when empty too: without it, `path.reshape` follows the earlier rule.
json!({ "id": press.id.0, "x": press.at.x, "y": press.at.y, "dx": d.x, "dy": d.y, "tol": cx.tol(4.0), "focal": others })
}
}
@@ -251,7 +251,8 @@ fn optional_number(d: &Dialog, key: &str) -> Result<Option<Value>, String> {
"v_scale" => tl!("Vertical Scale"),
"baseline_shift" => tl!("Baseline Shift"),
"rotation" => tl!("Character Rotation"),
"fill_tint" | "stroke_tint" => tl!("Tint"),
"fill_tint" => tl!("Fill Tint"),
"stroke_tint" => tl!("Stroke Tint"),
"stroke_width" => tl!("Weight"),
_ => return Err("unknown character style number field".into()),
};
@@ -804,19 +805,19 @@ fn paint_choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str) {
/// the stroke Weight and Overprint Fill and Overprint Stroke, each unset or set.
pub(crate) fn color_body(ui: &mut egui::Ui, d: &mut Dialog) {
egui::Grid::new("character-style-color").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| {
for (key, caption) in [("fill", tl!("Fill")), ("stroke", tl!("Stroke"))] {
for (key, caption, tint_name) in [("fill", tl!("Fill"), tl!("Fill Tint")), ("stroke", tl!("Stroke"), tl!("Stroke Tint"))] {
let row = ui.label(caption);
ui.label_inputs_by(row.id, |ui| paint_choice(ui, d, key));
ui.end_row();
let tint_key = format!("{key}_tint");
let row = ui.label(tl!("Tint"));
ui.label(tl!("Tint"));
if d.fields.get(&tint_key).is_none_or(Value::is_null) {
d.fields.insert(tint_key.clone(), json!(""));
}
let linked = d.fields.get(key).and_then(|p| p.get("swatch")).is_some_and(Value::is_string);
ui.add_enabled_ui(linked, |ui| {
ui.horizontal(|ui| {
ui.label_inputs_by(row.id, |ui| form::text(ui, d, &tint_key, 160.0));
form::text_named(ui, d, &tint_key, tint_name, 160.0);
ui.label("%");
});
});
@@ -1649,8 +1650,8 @@ mod tests {
fn color_tint_and_weight_outside_their_limits_are_errors() {
let linked = json!({"type": "solid", "color": {"model": "gray", "k": 0.5}, "swatch": "Ink"});
for (key, value, message) in [
("fill_tint", "150", "Tint must be a number from 0 to 100"),
("stroke_tint", "-1", "Tint must be a number from 0 to 100"),
("fill_tint", "150", "Fill Tint must be a number from 0 to 100"),
("stroke_tint", "-1", "Stroke Tint must be a number from 0 to 100"),
("stroke_width", "-1", "Weight must be a number from 0 to 4000000"),
("stroke_width", "thick", "Weight must be a number"),
] {
@@ -1893,6 +1894,46 @@ mod tests {
}
}
#[test]
fn fill_and_stroke_tint_fields_have_distinct_names() {
use egui::accesskit::Role;
use egui_kittest::kittest::{NodeT, Queryable};
for new_style in [false, true] {
let mut app = app();
app.run("swatch.new", json!({"name": "Brand", "color": "#2050a0", "global": true})).unwrap();
if new_style {
app.run("ui.newCharStyle", json!({})).unwrap();
} else {
app.run("charStyle.new", json!({"name": "Body", "attrs": {}})).unwrap();
open(&mut app, "Body").unwrap();
}
let brand = json!(app.session.doc().unwrap().doc.tint_paint("Brand", 1.0).unwrap());
{
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("section".into(), json!("color"));
d.fields.insert("fill".into(), brand.clone());
d.fields.insert("stroke".into(), brand);
}
let mut fonts = false;
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1100.0, 900.0)).build_ui_state(
move |ui, app: &mut VectorcraftApp| {
if !std::mem::replace(&mut fonts, true) {
crate::theme::install_fonts(ui.ctx());
return;
}
super::super::show(app, ui.ctx());
},
app,
);
h.run_steps(4);
for name in ["Fill Tint", "Stroke Tint"] {
let field = h.get_by_role_and_label(Role::TextInput, name);
assert!(!field.accesskit_node().is_disabled(), "new {new_style}: {name} is enabled for a linked swatch");
}
assert!(h.query_by_role_and_label(Role::TextInput, "Tint").is_none(), "new {new_style}: a field is named only Tint");
}
}
#[test]
fn invalid_number_keeps_last_valid_preview() {
let mut app = app();
+3
View File
@@ -3488,6 +3488,8 @@ digits Malayalam Малаялам
Tiles are numbered across, then down; leave the range empty for every tile. Плочките се номерират напречно, след това надолу; оставете диапазона празен за всяка плочка.
Copies per Sheet (as many as fit, in a grid) Копия на лист (колкото се побират, в мрежа)
Tint Оттенък
Fill Tint Оттенък на запълване
Stroke Tint Оттенък на щриха
Tip extends past the end point Връхът продължава след крайната точка
Tip on the end point Връх върху крайната точка
Title Case Заглавни букви
@@ -5559,6 +5561,7 @@ theme Appearance Външен вид
@msg Invalid drawing scale Невалиден мащаб на чертежа
@msg Invalid real scale Невалиден реален мащаб
@msg Measured anchor no longer exists Измерената опорна точка вече не съществува
@msg Invalid measured point Невалидна измерена точка
@msg Measured segment no longer exists Измереният сегмент вече не съществува
@msg Non-finite dimension Размерът не е крайно число
@msg Only linear dimensions are horizontal or vertical Само линейните размери са хоризонтални или вертикални
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2258,6 +2258,8 @@ digits Malayalam Malajalam
Green Zelena
Shifts the tints of global and spot colors; other colors stay. Pomjera nijanse globalnih i spot boja; ostale boje ostaju.
Tint Preliv
Fill Tint Preliv ispune
Stroke Tint Preliv poteza
Color Guide Options Opcije vodiča za boje
Less Manje
Steps: Koraci:
@@ -5607,6 +5609,7 @@ mark-style Japanese Japanski
@msg Invalid drawing scale Nevažeća skala crteža
@msg Invalid real scale Nevažeća stvarna skala
@msg Measured anchor no longer exists Izmjereno sidro više ne postoji
@msg Invalid measured point Neispravna izmjerena tačka
@msg Measured segment no longer exists Izmjereni segment više ne postoji
@msg Non-finite dimension Nekonačna kota
@msg Only linear dimensions are horizontal or vertical Samo linearne kote su horizontalne ili vertikalne
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -4478,6 +4478,8 @@ digits Malayalam Malaiàlam
Tilt Inclinació
Time (hhmmss) Hora (hhmmss)
Tint To
Fill Tint To de farciment
Stroke Tint To del traç
Tint Reduction Reducció de to
Tints Tons
Tints and Shades Tons i ombres
@@ -5371,6 +5373,7 @@ digits Malayalam Malaiàlam
@msg Make Guides: select paths Crea guies: seleccioneu traçats
@msg mask art ends outside a mask dictionary l’art de la màscara acaba fora d’un diccionari de màscara
@msg Measured anchor no longer exists El punt d’ancoratge mesurat ja no existeix
@msg Invalid measured point El punt mesurat no és vàlid
@msg Measured segment no longer exists El segment mesurat ja no existeix
@msg missing `folder` falta `folder`
@msg missing `id` (see plugin.list) falta `id` (vegeu plugin.list)
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2285,6 +2285,8 @@ digits Malayalam Malajálamské
Green Zelená
Shifts the tints of global and spot colors; other colors stay. Posune tóny globálních a přímých barev; ostatní barvy zůstanou.
Tint Tón
Fill Tint Tón výplně
Stroke Tint Tón tahu
Color Guide Options Možnosti průvodce barvami
Less Méně
Steps: Kroky:
@@ -5740,6 +5742,7 @@ mark-style Japanese Japonské
@msg Invalid drawing scale Neplatné měřítko výkresu
@msg Invalid real scale Neplatné skutečné měřítko
@msg Measured anchor no longer exists Měřený kotevní bod už neexistuje
@msg Invalid measured point Neplatný měřený bod
@msg Measured segment no longer exists Měřený segment už neexistuje
@msg Non-finite dimension Kóta není konečné číslo
@msg Only linear dimensions are horizontal or vertical Vodorovné nebo svislé jsou jen lineární kóty
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2091,6 +2091,8 @@ digits Malayalam Malayalam
Green Grøn
Shifts the tints of global and spot colors; other colors stay. Flytter nuancerne af globale farver og spotfarver; andre farver forbliver uændrede.
Tint Nuance
Fill Tint Fyldnuance
Stroke Tint Stregnuance
Color Guide Options Indstillinger for farveguide
Less Mindre
Steps: Trin:
@@ -5329,6 +5331,7 @@ mark-style Japanese Japansk
@msg Invalid drawing scale Ugyldig tegningsmålestok
@msg Invalid real scale Ugyldig virkelig målestok
@msg Measured anchor no longer exists Det målte ankerpunkt findes ikke længere
@msg Invalid measured point Ugyldigt målt punkt
@msg Measured segment no longer exists Det målte segment findes ikke længere
@msg Non-finite dimension Ikke-endeligt mål
@msg Only linear dimensions are horizontal or vertical Kun lineære mål er vandrette eller lodrette
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -3520,6 +3520,8 @@ effect Points Punkte
Copies per Sheet (as many as fit, in a grid) Kopien pro Blatt (so viele wie passen, im Raster)
Gutter: Zwischenraum:
Tint Tonwert
Fill Tint Flächentonwert
Stroke Tint Konturtonwert
Tip extends past the end point Spitze ragt über den Endpunkt hinaus
Tip on the end point Spitze auf dem Endpunkt
Title Case Wortanfänge groß
@@ -5317,6 +5319,7 @@ effect Soft Weich
@msg Invalid drawing scale Ungültiger Zeichnungsmaßstab
@msg Invalid real scale Ungültiger realer Maßstab
@msg Measured anchor no longer exists Der gemessene Ankerpunkt existiert nicht mehr
@msg Invalid measured point Ungültiger gemessener Punkt
@msg Measured segment no longer exists Das gemessene Segment existiert nicht mehr
@msg Non-finite dimension Die Bemaßung ist keine endliche Zahl
@msg Only linear dimensions are horizontal or vertical Nur lineare Bemaßungen sind horizontal oder vertikal
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2085,6 +2085,8 @@ font-technology Variable Μεταβλητές
Threshold Κατώφλι
Tile Παράθεση
Tint Χρωματική απόχρωση
Fill Tint Χρωματική απόχρωση γεμίσματος
Stroke Tint Χρωματική απόχρωση περιγράμματος
Tolerance Ανοχή
Tolerance: Ανοχή:
Tool Options… Επιλογές εργαλείου…
@@ -5695,6 +5697,7 @@ effect Points Σημεία
@msg Invalid drawing scale Μη έγκυρη κλίμακα σχεδίου
@msg Invalid real scale Μη έγκυρη πραγματική κλίμακα
@msg Measured anchor no longer exists Το μετρημένο σημείο αγκύρωσης δεν υπάρχει πλέον
@msg Invalid measured point Μη έγκυρο μετρημένο σημείο
@msg Measured segment no longer exists Το μετρημένο τμήμα δεν υπάρχει πλέον
@msg Non-finite dimension Η διάσταση δεν είναι πεπερασμένος αριθμός
@msg Only linear dimensions are horizontal or vertical Μόνο οι γραμμικές διαστάσεις είναι οριζόντιες ή κατακόρυφες
Can't render this file because it is too large.
+3
View File
@@ -3575,6 +3575,8 @@ digits Malayalam Malayálam
Copies per Sheet (as many as fit, in a grid) Copias por hoja (tantas como quepan, en cuadrícula)
Gutter: Medianil:
Tint Matiz
Fill Tint Matiz de relleno
Stroke Tint Matiz de trazo
Tip extends past the end point La punta se extiende más allá del punto final
Tip on the end point Punta en el punto final
Title Case Tipo Título
@@ -5681,6 +5683,7 @@ mark-style Japanese Japonesas
@msg Invalid drawing scale Escala de dibujo no válida
@msg Invalid real scale Escala real no válida
@msg Measured anchor no longer exists El punto de ancla medido ya no existe
@msg Invalid measured point Punto medido no válido
@msg Measured segment no longer exists El segmento medido ya no existe
@msg Non-finite dimension Cota no finita
@msg Only linear dimensions are horizontal or vertical Solo las cotas lineales son horizontales o verticales
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2244,6 +2244,8 @@ digits Malayalam Malajalam
Green Vihreä
Shifts the tints of global and spot colors; other colors stay. Siirtää globaalien värien ja spottivärien sävyjä; muut värit säilyvät.
Tint Sävy
Fill Tint Täytön sävy
Stroke Tint Viivan sävy
Color Guide Options Väriopasteen asetukset
Less Vähemmän
Steps: Vaiheet:
@@ -5707,6 +5709,7 @@ mark-style Japanese Japanilainen
@msg Invalid drawing scale Virheellinen piirustuksen mittakaava
@msg Invalid real scale Virheellinen todellinen mittakaava
@msg Measured anchor no longer exists Mitattua ankkuripistettä ei ole enää
@msg Invalid measured point Virheellinen mitattu piste
@msg Measured segment no longer exists Mitattua segmenttiä ei ole enää
@msg Non-finite dimension Mitta ei ole äärellinen luku
@msg Only linear dimensions are horizontal or vertical Vain lineaariset mitat ovat vaaka- tai pystysuuntaisia
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -3566,6 +3566,8 @@ digits Malayalam Malayalam
Copies per Sheet (as many as fit, in a grid) Copies par feuille (autant qu’il en tient, en grille)
Tiles are numbered across, then down; leave the range empty for every tile. Les carreaux sont numérotés de gauche à droite, puis de haut en bas ; laissez la plage vide pour tous les carreaux.
Tint Teinte
Fill Tint Teinte du fond
Stroke Tint Teinte du contour
Tip extends past the end point La pointe dépasse le point d’extrémité
Tip on the end point Pointe sur le point d’extrémité
Title Case Majuscule à chaque mot
@@ -5735,6 +5737,7 @@ theme Appearance Apparence
@msg Invalid drawing scale Échelle du dessin non valide
@msg Invalid real scale Échelle réelle non valide
@msg Measured anchor no longer exists Le point d’ancrage mesuré n’existe plus
@msg Invalid measured point Point mesuré non valide
@msg Measured segment no longer exists Le segment mesuré n’existe plus
@msg Non-finite dimension Cote non finie
@msg Only linear dimensions are horizontal or vertical Seules les cotes linéaires sont horizontales ou verticales
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -4853,6 +4853,8 @@ antiAlias Sharp शार्प
Tilt झुकाव
Time (hhmmss) समय (hhmms)
Tint टिंट
Fill Tint फ़िल टिंट
Stroke Tint स्ट्रोक टिंट
Tint Reduction टिंट में कमी
Tints टिंट्स
Tints and Shades टिंट्स और शेड्स
@@ -6623,6 +6625,7 @@ antiAlias Sharp शार्प
@msg matrix must be finite and invertible, depth finite matrix: मैट्रिक्स को परिमित और उलटा, गहराई परिमित होना चाहिए
@msg matte: {e} मैट: {e}
@msg Measured anchor no longer exists मापने वाला एंकर अब मौजूद नहीं है
@msg Invalid measured point मापा गया बिंदु अमान्य है
@msg Measured segment no longer exists मापदंड अब मौजूद नहीं है
@msg member is for order front, forward, backward or back member केवल order front, forward, backward या back के लिए है
@msg memory budget (the selection is too large to hand over) स्मृति बजट (चुनाव बहुत बड़ा है)
Can't render this file because it is too large.
+3
View File
@@ -4249,6 +4249,8 @@ mark-style Japanese Japán
Tilt Dőlés
Copies per Sheet (as many as fit, in a grid) Példány laponként (ahány elfér, rácsban)
Tint Tónus
Fill Tint Kitöltés tónusa
Stroke Tint Körvonal tónusa
Tints Tónusok
Tints and Shades Tónusok és árnyalatok
Tips Tippek
@@ -5722,6 +5724,7 @@ imageTrace Outline Körvonal
@msg Invalid drawing scale Érvénytelen rajzlépték
@msg Invalid real scale Érvénytelen valós lépték
@msg Measured anchor no longer exists A mért horgonypont már nem létezik
@msg Invalid measured point Érvénytelen mért pont
@msg Measured segment no longer exists A mért szakasz már nem létezik
@msg Non-finite dimension Nem véges méret
@msg Only linear dimensions are horizontal or vertical Csak a lineáris méretek lehetnek vízszintesek vagy függőlegesek
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2091,6 +2091,8 @@ digits Malayalam Malayalam
Green Hijau
Shifts the tints of global and spot colors; other colors stay. Menggeser tint warna global dan warna spot; warna lainnya tetap.
Tint Tint
Fill Tint Tint isian
Stroke Tint Tint stroke
Color Guide Options Opsi panduan warna
Less Lebih sedikit
Steps: Langkah:
@@ -5329,6 +5331,7 @@ mark-style Japanese Jepang
@msg Invalid drawing scale Skala gambar tidak valid
@msg Invalid real scale Skala sebenarnya tidak valid
@msg Measured anchor no longer exists Titik jangkar yang diukur sudah tidak ada
@msg Invalid measured point Titik yang diukur tidak valid
@msg Measured segment no longer exists Segmen yang diukur sudah tidak ada
@msg Non-finite dimension Dimensi bukan bilangan berhingga
@msg Only linear dimensions are horizontal or vertical Hanya dimensi linear yang horizontal atau vertikal
Can't render this file because it contains an unexpected character in line 2320 and column 5.
+3
View File
@@ -3612,6 +3612,8 @@ digits Malayalam Malayalam
Copies per Sheet (as many as fit, in a grid) Copie per foglio (quante ne entrano, in griglia)
Gutter: Spazio intermedio:
Tint Tinta
Fill Tint Tinta riempimento
Stroke Tint Tinta traccia
Tip extends past the end point La punta si estende oltre il punto finale
Tip on the end point Punta sul punto finale
Title Case Iniziali maiuscole
@@ -5352,6 +5354,7 @@ lighting Light Luce
@msg Invalid drawing scale Scala del disegno non valida
@msg Invalid real scale Scala reale non valida
@msg Measured anchor no longer exists Il punto di ancoraggio misurato non esiste più
@msg Invalid measured point Punto misurato non valido
@msg Measured segment no longer exists Il segmento misurato non esiste più
@msg Non-finite dimension Quota non finita
@msg Only linear dimensions are horizontal or vertical Solo le quote lineari sono orizzontali o verticali
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -3890,6 +3890,8 @@ file-filter Templates テンプレート
Text Wrap Options テキストの回り込みオプション
Tile Full Pages ページ全体を分割
Tint 濃度
Fill Tint 濃度 (塗り)
Stroke Tint 濃度 (線)
Tip extends past the end point 先端をパスの端点より先に伸ばす
Tip on the end point 先端をパスの端点に合わせる
Top 上
@@ -5395,6 +5397,7 @@ lighting Light ライト
@msg Invalid drawing scale 図面の縮尺が無効です
@msg Invalid real scale 実寸の縮尺が無効です
@msg Measured anchor no longer exists 測定したアンカーポイントはもう存在しません
@msg Invalid measured point 測定した点が無効です
@msg Measured segment no longer exists 測定したセグメントはもう存在しません
@msg Non-finite dimension 寸法が有限ではありません
@msg Only linear dimensions are horizontal or vertical 水平または垂直にできるのは直線寸法のみです
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -1995,6 +1995,8 @@ digits Malayalam მალაიალამური
Green მწვანე
Shifts the tints of global and spot colors; other colors stay. ცვლის გლობალური და სპოტ-ფერების ტონირებებს; სხვა ფერები უცვლელი რჩება.
Tint ტონირება
Fill Tint შევსების ტონირება
Stroke Tint კონტურის ტონირება
Color Guide Options ფერის გზამკვლევის პარამეტრები
Less ნაკლები
Steps: ბიჯები:
@@ -5346,6 +5348,7 @@ imageTrace Outline კონტური
@msg Invalid drawing scale ნახაზის არასწორი მასშტაბი
@msg Invalid real scale რეალური ზომის არასწორი მასშტაბი
@msg Measured anchor no longer exists გაზომილი საყრდენი წერტილი აღარ არსებობს
@msg Invalid measured point გაზომილი წერტილი არასწორია
@msg Measured segment no longer exists გაზომილი სეგმენტი აღარ არსებობს
@msg Non-finite dimension ზომა სასრული რიცხვი არ არის
@msg Only linear dimensions are horizontal or vertical მხოლოდ ხაზოვანი ზომებია ჰორიზონტალური ან ვერტიკალური
Can't render this file because it is too large.
+3
View File
@@ -3878,6 +3878,8 @@ file-filter Templates 템플릿
Text Wrap Options 텍스트 감싸기 옵션
Tile Full Pages 전체 페이지 타일링
Tint 색조
Fill Tint 칠 색조
Stroke Tint 선 색조
Tip extends past the end point 끝이 끝점을 지나 연장됨
Tip on the end point 끝이 끝점에 위치
Top 위쪽
@@ -5313,6 +5315,7 @@ lighting Light 광원
@msg Invalid drawing scale 잘못된 도면 축척
@msg Invalid real scale 잘못된 실제 축척
@msg Measured anchor no longer exists 측정한 고정점이 더 이상 없습니다
@msg Invalid measured point 측정한 점이 잘못되었습니다
@msg Measured segment no longer exists 측정한 세그먼트가 더 이상 없습니다
@msg Non-finite dimension 유한하지 않은 치수
@msg Only linear dimensions are horizontal or vertical 선형 치수만 가로 또는 세로로 지정할 수 있습니다
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2105,6 +2105,8 @@ digits Malayalam Malayalam
Green Groen
Shifts the tints of global and spot colors; other colors stay. Verschuift de tinten van globale kleuren en spotkleuren; andere kleuren blijven ongewijzigd.
Tint Tint
Fill Tint Vultint
Stroke Tint Contourtint
Color Guide Options Opties voor kleurengids
Less Minder
Steps: Stappen:
@@ -5341,6 +5343,7 @@ mark-style Japanese Japans
@msg Invalid drawing scale Ongeldige tekeningsschaal
@msg Invalid real scale Ongeldige werkelijke schaal
@msg Measured anchor no longer exists Gemeten ankerpunt bestaat niet meer
@msg Invalid measured point Ongeldig gemeten punt
@msg Measured segment no longer exists Gemeten segment bestaat niet meer
@msg Non-finite dimension Niet-eindige maatlijn
@msg Only linear dimensions are horizontal or vertical Alleen lineaire maatlijnen zijn horizontaal of verticaal
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2201,6 +2201,8 @@ digits Malayalam Malajalam
Green Zielony
Shifts the tints of global and spot colors; other colors stay. Przesuwa tinty kolorów globalnych i dodatkowych; pozostałe kolory bez zmian.
Tint Tinta
Fill Tint Tinta wypełnienia
Stroke Tint Tinta obrysu
Color Guide Options Opcje przewodnika kolorów
Less Mniej
Steps: Kroki:
@@ -5592,6 +5594,7 @@ mark-style Japanese Japońskie
@msg Invalid drawing scale Nieprawidłowa skala rysunku
@msg Invalid real scale Nieprawidłowa skala rzeczywista
@msg Measured anchor no longer exists Mierzony punkt kontrolny już nie istnieje
@msg Invalid measured point Nieprawidłowy mierzony punkt
@msg Measured segment no longer exists Mierzony odcinek już nie istnieje
@msg Non-finite dimension Wymiar nie jest liczbą skończoną
@msg Only linear dimensions are horizontal or vertical Tylko wymiary liniowe są poziome lub pionowe
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -5019,6 +5019,8 @@ axis Both Ambos
Text Wrap Options Opções de Quebra de Texto
Tile Full Pages Dividir em Páginas Inteiras
Tint Tom
Fill Tint Tom de Preenchimento
Stroke Tint Tom do Traçado
Tip extends past the end point A ponta se estende além do ponto final
Tip on the end point Ponta no ponto final
Toolbar: Add Tool Barra de Ferramentas: Adicionar Ferramenta
@@ -5671,6 +5673,7 @@ mark-style Japanese Japonesas
@msg Invalid drawing scale Escala de desenho inválida
@msg Invalid real scale Escala real inválida
@msg Measured anchor no longer exists O ponto de ancoragem medido não existe mais
@msg Invalid measured point Ponto medido inválido
@msg Measured segment no longer exists O segmento medido não existe mais
@msg Non-finite dimension Cota não finita
@msg Only linear dimensions are horizontal or vertical Somente cotas lineares são horizontais ou verticais
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -3606,6 +3606,8 @@ digits Malayalam Малаялам
Tiles are numbered across, then down; leave the range empty for every tile. Плитки нумеруются слева направо, затем вниз; оставьте диапазон пустым для всех плиток.
Copies per Sheet (as many as fit, in a grid) Копий на лист (сколько поместится, сеткой)
Tint Оттенок
Fill Tint Оттенок заливки
Stroke Tint Оттенок обводки
Tip extends past the end point Кончик выходит за конечную точку
Tip on the end point Кончик на конечной точке
Title Case Как в заголовке
@@ -5346,6 +5348,7 @@ lighting Light Свет
@msg Invalid drawing scale Недопустимый масштаб чертежа
@msg Invalid real scale Недопустимый реальный масштаб
@msg Measured anchor no longer exists Измеряемая опорная точка больше не существует
@msg Invalid measured point Недопустимая измеряемая точка
@msg Measured segment no longer exists Измеряемый сегмент больше не существует
@msg Non-finite dimension Размер не является конечным числом
@msg Only linear dimensions are horizontal or vertical Только линейные размеры бывают горизонтальными или вертикальными
Can't render this file because it is too large.
+3
View File
@@ -3496,6 +3496,8 @@ digits Malayalam Malayalam
Tiles are numbered across, then down; leave the range empty for every tile. Rutorna numreras från vänster till höger och sedan nedåt. Lämna omfånget tomt för att få med alla rutor.
Copies per Sheet (as many as fit, in a grid) Kopior per ark (så många som får plats, i ett rutnät)
Tint Färgton
Fill Tint Fyllnadsfärgton
Stroke Tint Konturfärgton
Tip extends past the end point Spetsen sträcker sig förbi slutpunkten
Tip on the end point Spetsen på slutpunkten
Title Case Inledande versaler
@@ -5699,6 +5701,7 @@ theme Appearance Utseende
@msg Invalid drawing scale Ogiltig ritningsskala
@msg Invalid real scale Ogiltig verklig skala
@msg Measured anchor no longer exists Den mätta ankarpunkten finns inte längre
@msg Invalid measured point Ogiltig mätt punkt
@msg Measured segment no longer exists Det mätta segmentet finns inte längre
@msg Non-finite dimension Måttet är inte ett ändligt tal
@msg Only linear dimensions are horizontal or vertical Endast linjära mått är vågräta eller lodräta
Can't render this file because it contains an unexpected character in line 5 and column 25.
+3
View File
@@ -3385,6 +3385,8 @@ digits Malayalam Malayalam
tile {number} kiremit {number}
Tiles are numbered across, then down; leave the range empty for every tile. Fayanslar önce baştan sonra aşağı doğru numaralandırılır; aralığı her döşeme için boş bırakın.
Tint Renk tonu
Fill Tint Dolgu Renk Tonu
Stroke Tint Kontur Renk Tonu
Tip extends past the end point İpucu bitiş noktasının ötesine uzanıyor
Tip on the end point Bitiş noktasına dair ipucu
Title Case Başlık Durumu
@@ -5420,6 +5422,7 @@ theme Appearance Görünüm
@msg Invalid drawing scale Geçersiz çizim ölçeği
@msg Invalid real scale Geçersiz gerçek ölçek
@msg Measured anchor no longer exists Ölçülen çapa artık mevcut değil
@msg Invalid measured point Geçersiz ölçülen nokta
@msg Measured segment no longer exists Ölçülen segment artık mevcut değil
@msg Non-finite dimension Sonlu olmayan boyut
@msg Only linear dimensions are horizontal or vertical Yalnızca doğrusal boyutlar yatay veya dikey olabilir
Can't render this file because it contains an unexpected character in line 5 and column 25.
+3
View File
@@ -3605,6 +3605,8 @@ effect Points Точки
Copies per Sheet (as many as fit, in a grid) Копій на аркуш (скільки поміститься, сіткою)
Gutter: Проміжок:
Tint Відтінок
Fill Tint Відтінок заливки
Stroke Tint Відтінок обведення
Tip extends past the end point Кінчик виходить за кінцеву точку
Tip on the end point Кінчик на кінцевій точці
Title Case Як у заголовку
@@ -5332,6 +5334,7 @@ lighting Light Світло
@msg Invalid drawing scale Неприпустимий масштаб креслення
@msg Invalid real scale Неприпустимий реальний масштаб
@msg Measured anchor no longer exists Виміряна опорна точка більше не існує
@msg Invalid measured point Недійсна виміряна точка
@msg Measured segment no longer exists Виміряний сегмент більше не існує
@msg Non-finite dimension Розмір не є скінченним числом
@msg Only linear dimensions are horizontal or vertical Лише лінійні розміри бувають горизонтальними або вертикальними
Can't render this file because it contains an unexpected character in line 182 and column 5.
+3
View File
@@ -3952,6 +3952,8 @@ mark-style Japanese Kiểu Nhật
Copies per Sheet (as many as fit, in a grid) Nhiều bản sao trên một tờ (xếp vừa bao nhiêu in bấy nhiêu, theo lưới)
Gutter: Khoảng cách:
Tint Tông màu
Fill Tint Tông màu tô
Stroke Tint Tông màu nét
Tints Sắc pha
Tints and Shades Sắc pha và bóng
Tips Mẹo
@@ -5333,6 +5335,7 @@ theme Appearance Giao diện
@msg Invalid drawing scale Tỷ lệ bản vẽ không hợp lệ
@msg Invalid real scale Tỷ lệ thực không hợp lệ
@msg Measured anchor no longer exists Điểm neo được đo không còn tồn tại
@msg Invalid measured point Điểm được đo không hợp lệ
@msg Measured segment no longer exists Đoạn được đo không còn tồn tại
@msg Non-finite dimension Kích thước không phải là số hữu hạn
@msg Only linear dimensions are horizontal or vertical Chỉ kích thước tuyến tính mới nằm ngang hoặc thẳng đứng
Can't render this file because it contains an unexpected character in line 204 and column 5.
+3
View File
@@ -3438,6 +3438,8 @@ digits Malayalam 马拉雅拉姆数字
Tiles are numbered across, then down; leave the range empty for every tile. 拼贴先由左至右再由上至下编号;范围留空表示全部拼贴。
Copies per Sheet (as many as fit, in a grid) 每张纸印多份 (尽量排满,按网格排列)
Tint 色调
Fill Tint 填色色调
Stroke Tint 描边色调
Tip extends past the end point 箭头超出端点
Tip on the end point 箭头位于端点
Title Case 词首大写
@@ -4783,6 +4785,7 @@ imageTrace Outline 轮廓
@msg Invalid drawing scale 图纸比例无效
@msg Invalid real scale 实际比例无效
@msg Measured anchor no longer exists 测量锚点已不存在
@msg Invalid measured point 测量点无效
@msg Measured segment no longer exists 测量线段已不存在
@msg Non-finite dimension 尺寸标注不是有限值
@msg Only linear dimensions are horizontal or vertical 只有线性尺寸可以是水平或垂直的
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2609,6 +2609,7 @@ file-filter Templates 範本
@msg Invalid drawing scale 圖紙比例無效
@msg Invalid real scale 實際比例無效
@msg Measured anchor no longer exists 測量的錨點已不存在
@msg Invalid measured point 測量點無效
@msg Measured segment no longer exists 測量的線段已不存在
@msg Non-finite dimension 尺寸標註不是有限值
@msg Only linear dimensions are horizontal or vertical 只有線性尺寸可以是水平或垂直的
@@ -4552,6 +4553,8 @@ digits Malayalam 馬來亞拉姆數字
Copies per Sheet (as many as fit, in a grid) 每張紙印多份 (盡量排滿,以格點排列)
Gutter: 間距:
Tint 色調
Fill Tint 填色色調
Stroke Tint 筆畫色調
Tip extends past the end point 尖端延伸超過終點
Tip on the end point 尖端位於終點
Title Case 標題式大小寫
Can't render this file because it contains an unexpected character in line 6 and column 47.
+16 -5
View File
@@ -6,7 +6,13 @@ The **Dimension Tool** is in the Modify section of the toolbar. Its strip offers
- **Linear:** hover a path segment and drag outwards: releasing places the dimension. Or click two
points (anchors snap, Shift constrains to 45°), then click to place. A click within 8 pixels of
an anchor of any path, in groups and compound paths too, takes that anchor, and the dimension
stays linked to it. A press on an anchor picks it when the pointer moves less than 8 pixels
stays linked to it. A point on a segment stays at its place along the segment, also when the
segment's anchors or handles are edited. Any other point is linked to the path under it, or,
with no path under it, to the path at the other end, and moves, scales, turns and reflects with
that path. Beside a path whose anchors and handles all lie on one line, such as a straight line,
the point keeps its distance along and across the path: a reflection moves it as a half turn
does, and a scale that is not uniform changes its distance across the path in proportion to the
path's length. A press on an anchor picks it when the pointer moves less than 8 pixels
before the release; a longer drag drags out the dimension of the segment under the pointer. A
curved segment is measured from end to end. With Smart Guides on, both points also snap into line with other objects'
anchors and centres, and the second onto the construction guides through the first; the guides
@@ -52,10 +58,15 @@ position and alignment; changing one changes that dimension. **Apply to All** gi
dimension in the document its style; **Set as Default** makes it the style of new dimensions.
Measurements follow the art they measure through moves, scaling and anchor and handle edits,
undo and redo included. Deleting the art, or changing its number of anchors or whether it is
closed, removes its dimensions in the same undo step rather than attaching them to a different
edge. A linked dimension moved on its own stays where it was dropped (its distance from the art
changes); moved together with its art, it keeps its distance.
undo and redo included. When the art is reflected, the line of a linked linear dimension goes to
the reflected side, as far past the outermost point as before. This does not happen for art whose
anchors and handles all lie on one line, such as a straight line, which a half turn puts in the
same place as a reflection. Deleting the art, changing its number of anchors or whether it is
closed, turning an angle's arm into a curve, or making a measured circle or arc no longer circular
leaves its dimensions where they were, no longer linked, in the same undo step, rather than
attaching them to a different edge. Undo links them again. A linked dimension moved on its own
stays where it was dropped (its distance from the art changes); moved together with its art, it
keeps its distance.
Native `.vectorcraft` files keep the recipes and links. SVG, PDF and bitmap output contain
ordinary paths and text, so exported dimensions look the same. Ungrouping a dimension leaves its
+2 -1
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (restored File Info titles retain authored spaces, #3626; previously: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.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)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (restored File Info titles retain authored spaces, #3626; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.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
@@ -341,6 +341,7 @@ Stop opacity on translated artboards remains outside this regression coverage.
|---|---|---|
| 2026-11-11 | minor | Native and Preserve Editing restores keep an existing File Info title exactly, including repeated spaces; imported-text fallback titles alone are normalized (#3626). |
| 2026-11-03 | minor | An imported document's own title goes into File Info only (#3626): an SVG's root `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title` is File Info's title (`metadata.title` in the native format, left out when unset so other documents save unchanged and older versions ignore it; written by exports); the document's name, which the tab, Save As and the default export names use, stays the file's name. |
| 2026-11-11 | minor | `.ai` and EPS import: stroked type in an envelope with Distort Appearance is tried mapped from its glyphs grown by as far as its strokes paint along a side (half the weight of a centred stroke), without what miter joins add at corners, for a stroke on its appearance or on a group round it as well as its characters'; it was mapped from its bounds with strokes, which reach as far as a miter join may at a corner. Tests `ai::envelope::tests::stroked_type_is_tried_mapped_from_its_glyphs_grown_by_half_its_stroke`, `type_with_a_character_stroke_is_tried_mapped_from_its_bounds_with_strokes`, `a_stroke_on_a_group_of_type_grows_the_box_by_half_its_weight` (part of #3179) |
| 2026-11-11 | minor | `.ai` and EPS import: a warp of type (an envelope made with a warp around type) whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the warp grid the file saves, with the same drawn-art check, budget and page comparison as #6421: where it passes, the type is editable and keeps the file's shape, and the warp's settings (style, bend) are lost. A second try is only made while more checks are left than there are envelopes of type and warped text objects still to come. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its box with strokes, where the size the file saves for it doesn't already give that box (a rule chosen by measuring real files). An envelope's grid (warp, mesh or top object) now reads its way round from all its rows and columns, not its first row alone: a mesh bent far round (its end columns leaning more than halfway over) reads the way it runs, not turned a quarter round, and a grid symmetric about its middle reads the same wherever it is on the page. Tests `tests_native::a_warp_of_type_opens_as_a_mesh_on_its_saved_grid_and_keeps_its_shape_when_its_text_changes`, `a_warp_of_stroked_type_maps_from_its_bounds_with_strokes_where_its_drawn_art_does`, `a_warp_of_type_without_a_saved_grid_keeps_its_drawn_art`, `a_warp_of_type_tried_again_leaves_the_next_envelope_its_check`, `ai::tests::a_mesh_envelope_bent_far_round_opens_the_way_it_runs`, `a_mesh_envelope_bent_halfway_round_opens_the_same_way_wherever_it_is` (part of #3179) |
| 2026-11-11 | minor | `.ai` and EPS import: type stroked in the text document keeps its stroke's cap, join and miter limit (keys 61, 62 and 64 of its character attributes, numbered as PostScript's `setlinecap` and `setlinejoin`: 0 butt or miter, 1 round, 2 projecting or bevel). They were left at a miter join with a limit of 10 whatever the file said, so type the file strokes with round joins, or with the usual limit of 4 or less, drew spikes at its sharp corners, some many points long. In users' files, used locally and never committed, the page strokes the type with the cap, join and miter limit these keys give; no file had a bevel join, which is read by the same numbering. A value that isn't one of theirs keeps the default, a miter limit past 500 is 500, and type without a stroke keeps the defaults. Dashes are not read: no file had dashed type to tell which keys hold them. Tests `ate::tests::a_run_keeps_its_stroke_cap_join_and_miter_limit`, `tests_native::stroked_type_keeps_its_cap_join_and_miter_limit`; scores unchanged |
| 2026-11-10 | minor | `.ai` and EPS import: an envelope of type (a warp, a mesh or a top object around type) kept its drawn art, its type hidden, because the type is laid out from the text document only after the layers are read. It now waits for that: its text slots get their type, and where VectorCraft's own envelope of that type draws as the drawn art the file saves (the same check and budget as other envelopes, seen on white and on black), it opens live in the drawn art's place, its type editable in Edit Contents, and the hidden copy goes. As with other live envelopes, the layers with one are then compared with the page, which weighs the envelope as the art it draws (not as type laid out again, which the comparison allows more for), and when they don't draw as it the drawn art and the hidden type are put back. Layers used though they don't draw as the page (the editing data trusted, or the objects that differ coming in as the page's art) leave envelopes of type their drawn art and hidden type too. A `.ai` whose layers would open alone, without its PDF part, imports the PDF part to compare them with, and should they still not draw as it, or should no envelope of type open live, they open alone as before, with the envelopes' drawn art. Type in a font that isn't available, slots without type of their own (threaded, bound to a text or a visibility variable, a wrap object, type a look draws), envelopes in a symbol's or a pattern's art, and envelopes that draw differently keep their drawn art, with the note (#3179). Tests `tests_native::an_envelope_of_type_that_draws_as_its_drawn_art_opens_live`, `an_envelope_of_type_that_draws_apart_from_its_drawn_art_keeps_it`, `an_envelope_of_white_type_that_draws_apart_from_its_drawn_art_keeps_it`, `an_envelope_of_type_waits_on_the_page_of_layers_that_would_open_alone`, `an_envelope_of_type_that_draws_apart_from_the_page_does_not_open_live`, `trusted_layers_that_draw_apart_from_the_page_leave_an_envelope_of_type_its_drawn_art`, `layers_with_objects_that_come_in_as_the_page_art_leave_an_envelope_of_type_its_drawn_art`, `an_envelope_of_type_bound_to_a_visibility_variable_keeps_its_drawn_art` |
+62 -11
View File
@@ -836,7 +836,8 @@ page with hint tables saying where every other page's objects are), which stays
`compatibility: "1.3"` writes a PDF 1.3 file, which has no transparency: a copy of the document is flattened first
with `flattenerPreset` (empty: High Resolution; `high`, `medium`, `low` or a saved preset, `flattener.presets.list`)
and `flattener` options over it (as `object.flattenTransparency` takes them); images with see-through pixels count as
transparency and rasterized areas are clipped to their regions. A file that would still have transparency fails the
transparency and rasterized areas are clipped to their regions. Layers the file leaves out (non-printing ones, unless
`includeNonPrinting` or `createLayers` is set) are left out of the flattening. A file that would still have transparency fails the
export. PDF/X-1a and PDF/X-3 files are PDF 1.3 files too.
A page with a side longer than 14,400 pt is written with a `UserUnit` (PDF 1.6): its longer side divided by 14,400,
rounded up to the next thousandth, with the page's sizes and coordinates in those units, so that no side is longer than
@@ -1549,7 +1550,7 @@ describes that menu item. Pass exactly one of `path` and `source`. A `path` must
case. A `.jsxbin` file or text that starts with `@JSXBIN@` is compiled and ends with an error, as does text that is
not UTF-8. The language is ES5.1, except that identifiers take the characters Unicode's `ID_Start` and
`ID_Continue` properties allow, as later editions define them, and may use `\u{...}` escapes. The script reaches the
app through the global object `vc`, and writes to the Script Console through `$`:
app through the global object `vc`, and writes to the Script Console and runs other files through `$`:
- `vc.run(id, params)` runs a command and returns its result. A command that fails throws an error the script can
catch. The documents and the session state the command changed, preferences included, go back, and any file it
@@ -1561,9 +1562,41 @@ app through the global object `vc`, and writes to the Script Console through `$`
- `vc.log(value)` adds a value to the report and a line to the Script Console.
- `$.write(...values)` writes the values to the Script Console with no separator, each as `String(value)` gives it, and
`$.writeln(...values)` does the same and ends the line. Each call counts as a host call.
- `$.evalFile(path, timeout?)` runs the file `path` names, relative to the file being run, in the same global
environment, so its functions and variables stay defined, and returns its last value (`undefined` for an empty
file). Its preprocessor lines are read as the script's are. `timeout` is a number of milliseconds from 0; the file
is read at once and `timeout` is not waited on. The run's fuel and limits apply to the file. An empty path, a
missing file and a refused path throw errors the script can catch.
- `$.fileName` is the file being run, which is the file `$.evalFile` is running while it runs, and is empty for a
script given as `source`. `$.includePath` is the current `#includepath` list, separated by `;`.
- `vc.args` is the `args` object.
- `vc.api.version` is `"1.0"`, and `vc.api.has(feature)` says whether the API has a feature.
Before any of the script runs, its preprocessor lines are read. The help page
https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/ describes these lines, and
https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/ describes `$.evalFile`, `$.fileName`
and `$.includePath`. A line counts when it starts with `#` or `//@`, after blanks only, outside strings and comments.
VectorCraft reads them by these rules of its own:
- `#include "file"` and `//@include "file"` put the text of that file in place of the line. A value with spaces or
punctuation goes in single or double quotes. The file is looked for beside the file that includes it, then in each
`#includepath` folder in turn, then in each folder of the `JSINCLUDE` environment variable (absolute folders
separated by `;`). The included file must be a whole script on its own. A file may be included again after its
earlier inclusion has ended, and a file that includes itself, directly or through others, is an error.
- `#includepath "a;b"` sets the folders `#include` looks in, separated by `;`. A relative folder is relative to the
folder of the file that includes. A later `#includepath` replaces the list.
- `#script "name"` names the run in the report and the Script Console, unless `name` is given. A `#script` line in an
included file does not rename the run.
- `#target` takes any value and does not change how the script runs. `#strict off` is taken.
- `#strict on`, `#targetengine` and `#engine` end the run with an unsupported-directive error, and any other `#name`
with an unknown-directive error. A `//@` line with another name is a comment.
- A script run from a file reads its included files and the files `$.evalFile` runs only in that file's folder and
the folders below it. A path with a `..` name, a path that leads out of the folder through a link or a junction, a
Windows device name or stream, and a computer on the network are refused, and so is every path for a script given as
`source`. A `JSINCLUDE` folder outside the script's folder is passed over.
- A missing or refused included file ends the run before any of it runs. An error in an included file or a file `$.evalFile`
ran is reported at that file's line and column, and the report's `file` names the file.
The run is one transaction. When the script ends, each document it changed gets one undo step named Run Script. A
run that changes no document adds no undo step. The journal records each command the run finished, as it records
commands run one at a time. A preference the run changed takes the run's value, and a preference changed in the app
@@ -1590,7 +1623,8 @@ VectorCraft sets these limits itself:
| Limit | Value |
|-------|-------|
| Source text | 8 MiB |
| Source text | 8 MiB a file, 32 MiB in all |
| Included and evaluated files | 64 in all, 16 levels deep, 1,024 paths looked up |
| Heap | 256 MiB (128 MiB on the web) |
| Host calls | 2,000,000 |
| `maxFuel`, instructions in all | 1 to 100,000,000,000; 1,000,000,000 by default |
@@ -1607,7 +1641,8 @@ In the app, a script past the growth limit stops at a question to continue or to
with an error at that limit. The headless reply is `{ok: true, name, changed, written, log, fuelUsed, hostCalls,
runTimeMs, timingAvailable, hostCallCounts, slowestCommands, console}`. In the app the reply is `{running: true,
name}`, and the report appears later as `scriptReport` in `ui.inspect`. The report of a failed run has the same
fields after `kind`, `message`, `line`, `column` and `fuelLeft`. `answers` takes only an empty object, since scripts
fields after `kind`, `message`, `line`, `column`, `file` (when the error is in another file than the script) and
`fuelLeft`. `answers` takes only an empty object, since scripts
have no prompts yet.
- `runTimeMs` is the time the script ran, its commands included. Waits between frames and for a person's answer are
@@ -1626,7 +1661,8 @@ vectorcraft-cli run --cmd script.run --params '{"path": "Tidy Layers.js", "args"
```
Window › Script Console shows the session's script lines, oldest first: each run's start, its `$.write` and
`$.writeln` output, its `vc.log` entries, its uncaught error as `name:line:column: message`, its end, and a report of
`$.writeln` output, its `vc.log` entries, its uncaught error as `name:line:column: message` (or `name: file:line:column: message` when the error is in another
file), its end, and a report of
its time, host calls and slowest commands. Each line shows the run's number and the script's name. Line feeds,
carriage returns and the two together break lines, and other control characters show as their Unicode Control
Pictures. The console keeps at most 2,048 lines and 1 MiB of text and drops the oldest lines past that. A line longer
@@ -3161,6 +3197,13 @@ one undo step. The targets split into groups of overlapping objects; groups with
Each other group becomes one group of atomic regions: paths filled with the flat colour the art showed there (over
white, which blend modes do not read; over nothing, keeping alpha, with `preserveAlpha: true`), plus one image where gradients, patterns, images,
opacity masks or raster effects reach (clipped to those regions with `clipComplexRegions`, a rectangle without).
The image leaves out the part of such art that the clipping path of a clip group or clipping layer hides, so art under
the hidden part of a cropped picture stays as it is. Transparent art that such a clipping path hides entirely is
removed. A group that holds art from inside and outside a clipping layer goes in the place of the topmost art outside
it. When that layer prints, shows or is a template where the clipping layer differs, the clipping layer or a group
around the art has opacity, a blend mode or isolation, or the clipping path has a fill,
the group holds only the art inside the clipping layer, drawn over the fill and over the art below in the same kind of
layer as its own layers draw it (over nothing with `preserveAlpha`), and the art outside is flattened on its own.
`preset` is `high`, `medium` (the default) or `low`; option keys (`balance` 0–100, `lineArtPpi`, `gradientPpi`,
`textToOutlines`, `strokesToOutlines`, `clipComplexRegions`, `antiAlias`, `preserveAlpha`, `preserveOverprints`)
override it, at the top level or in `options`. `balance: 0` rasterizes everything; lower balances rasterize groups
@@ -3724,14 +3767,18 @@ the canvas (coordinates out of range)".
`path.setHandle`, `path.moveAnchor` and `path.insertAnchor` edit subpath 0 when `subpath` is absent or null, and refuse a `subpath` that
is not a whole number of 0 or more, such as 1.5, -1 or "1" ("missing integer `subpath`"); `path.split` refuses such
an `anchor` or `segment` in the same way. The document does not change and no undo step is recorded.
`path.insertAnchor` keeps the direct-selected anchors and selected segments of the path on the same points: those
after the new anchor on its subpath are numbered one higher, and a selected segment that the new anchor splits stays
selected as its two halves, with the new anchor as an end of both that moves with them. Undo restores the path and
the selection in one step.
`path.reshape {id, x, y, dx, dy, tol?, focal?, radius?}` (a Reshape tool drag) grabs the path at `x`, `y`, adding an
anchor there unless one lies within `tol` (3 by default), and moves that point by `dx`, `dy`.
[Adjust path segments](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/adjust-path-segments.html)
has the Reshape tool stretch part of a path from the dragged point without losing the path's general form, with every
highlighted focal point pulling the path. Which anchors stay, how far the stretch reaches and the share of the drag each
point takes are VectorCraft's own rule. The grabbed point and the anchors that `focal` lists as `[subpath, anchor]` pairs are focal
points and move by the whole drag; a pair that names no anchor of the path is refused ("`focal` names [0, 7], which is
point takes are VectorCraft's own rule. When `focal` is given (an empty list included), the grabbed point and the
anchors that `focal` lists as `[subpath, anchor]` pairs are focal points and move by the whole drag; a pair that names no anchor of the path is refused ("`focal` names [0, 7], which is
not an anchor of the path"). Anchors that are not direct-selected stay in place (on a path selected whole, or not
selected, every anchor is selected), and so do the two ends of an open path unless they are focal points. A selected
anchor between two focal points moves by the whole drag. A selected anchor between a focal point and an anchor that
@@ -3744,8 +3791,11 @@ point does not move. An anchor added on a segment of a partly selected path is s
anchors stay selected. With `radius`, only the grabbed point's subpath changes, whatever the selection: the anchors
within `radius` of the grabbed point along the path follow with a smooth falloff and the others stay; a negative
`radius` is refused ("`radius` must be 0 or more"), and so is `radius` given with `focal` ("`focal` and `radius`
cannot be used together"). A drag of no length on an anchor (`dx` and `dy` 0 or left out) leaves the path as it is
and records no undo step.
cannot be used together"). Without `focal` (or with `focal` null) and without `radius`, the earlier rule applies, so
steps recorded before focal points play as they did: the selection does not matter, an open path stretches from the
grabbed point to its two ends as above, and on a closed path the anchors within half the diagonal of the subpath's
bounding box follow with the smooth falloff. The Reshape tool always sends `focal`. A drag of no length on an anchor
(`dx` and `dy` 0 or left out) leaves the path as it is and records no undo step.
With the Reshape tool, a click on an anchor of a selected path makes it the focal point, and a click on a segment adds
an anchor there (one undo step) that becomes the focal point. Shift-click adds an anchor or a point on a segment to the
@@ -6497,8 +6547,9 @@ with binding punctuation (`Tg`) on, which opens as `forced`. `none` isn't saved.
`text.setFormat` and `text.setRangeStyle {tcyUpDown?, tcyLeftRight?}` set the Character panel menu's Tate-chu-yoko
Settings of the characters, in points from -1296 to 1296 (document points, as `baselineShift`). In vertical type a
block of tate-chu-yoko characters (`tateChuYoko`) moves up by a positive Up/Down and down by a negative one, and right
by a positive Left/Right and left by a negative one. The block moves as one piece with the values of its first
character, and keeps its one em down the column, its caret positions and its hit region. Two or three digits set
by a positive Left/Right and left by a negative one. On a vertical path, a positive Up/Down moves the block toward the
path's start and a positive Left/Right moves it to the path's left, with every path effect. The block moves as one piece
with the values of its first character, and keeps its one em down the column, its caret positions and its hit region. Two or three digits set
across the column without the mark do not move, and horizontal type keeps the values without moving the characters.
Neither value turns `tateChuYoko` on. Both are 0 by default and saved only when not 0, and character styles carry
them. A value that is not a number is refused with `` `tcyUpDown` must be a number ``, and one outside the range with
+1 -11
View File
@@ -143,25 +143,15 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l
| M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default | 4–8 |
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, three generated libraries (Basic Shapes, UI Frames, Map Markers), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, four generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 |
| M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged) | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol and brush libraries done; symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 |
| M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, three generated libraries (Basic Shapes, UI Frames, Map Markers), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, four generated libraries, User Defined, Other Library…, Save Brushes); brushes/symbols in progress | 23–37 |
| M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged) | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol and brush libraries done; brushes/symbols in progress | 23–37 |
| M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369) | 6–10 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; `script.run` and File › Scripts › Other Script… run JavaScript files, and Window › Script Console shows their output; the script document model and batch work remain | 18–27 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Icon-strip flyouts resize on their free edges and retain sizes (#5713); interim macOS cursor delivery (#2292) uses existing artwork and host-owned AppKit cursors; headless coverage only, native menu/focus/handback and display validation pending. Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset <name>, UI.7), Window › Help Bar shown or hidden apart from the status bar and kept in saved workspaces, panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), with pointer and native-window regression coverage; estimates unchanged; numeric spinner arrows keep repeating while held with the mouse (#6307) | 18–28 |
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ (new ruler-guide Alt orientation implemented; existing-guide Escape cancellation regression-tested; broader pass remains) | 60–90 |
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 PngSuite integrity and bounded fetching verified (#1729); real-world corpus/manual QA remain | 50–80 |
File diff suppressed because one or more lines are too long