Merge main into fix/plugin-grant-module-hash

This commit is contained in:
dotCooCoo
2026-11-08 15:55:53 -08:00
169 changed files with 12292 additions and 645 deletions
Generated
+1
View File
@@ -6384,6 +6384,7 @@ dependencies = [
name = "vectorcraft-js"
version = "0.21.0"
dependencies = [
"chrono",
"proptest",
"unicode-general-category",
]
+14
View File
@@ -321,6 +321,20 @@ pub struct SeriesPaint {
/// Opacity (0..1) of the topmost stroke of each path directly in the series group, as [`Self::fill_opacity`].
#[serde(skip_serializing_if = "Option::is_none")]
pub stroke_opacity: Option<f32>,
/// Blend mode of the topmost fill of each path directly in the series group, as [`Self::fill_opacity`]. Absent: Normal.
#[serde(skip_serializing_if = "Option::is_none")]
pub fill_blend: Option<vectorcraft_color::BlendMode>,
/// Blend mode of the topmost stroke of each path directly in the series group, as [`Self::fill_opacity`]. Absent:
/// Normal.
#[serde(skip_serializing_if = "Option::is_none")]
pub stroke_blend: Option<vectorcraft_color::BlendMode>,
/// Object opacity (0..1) of each path directly in the series group, taken from the object opacity set on one of them.
/// Absent: fully opaque.
#[serde(skip_serializing_if = "Option::is_none")]
pub opacity: Option<f32>,
/// Object blend mode of each path directly in the series group, as [`Self::opacity`]. Absent: Normal.
#[serde(skip_serializing_if = "Option::is_none")]
pub blend: Option<vectorcraft_color::BlendMode>,
}
/// Graph data and options. `rows` are categories (one per row of the Graph Data window), each
+22 -10
View File
@@ -437,7 +437,7 @@ pub fn locked_objects_at(doc: &Document, p: Point, opt: HitOptions) -> Vec<NodeI
/// whole, and an unlocked layer, group, blend or envelope in Edit Contents is searched inside.
fn locked_children(parent: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, out: &mut Vec<NodeId>) {
let Some(children) = parent.children() else { return };
if parent.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) {
if parent.clips() && !edits_inside(parent, opt.scope) && children.first().is_some_and(|clip| !clip_contains(clip, p)) {
return;
}
for i in hit_order(parent, children, p) {
@@ -653,6 +653,15 @@ fn clip_outline_hit(parent: &Node, clip: &Node, p: Point, opt: HitOptions, symbo
clip_outline(clip, p, opt)
}
/// Does isolation mode (`scope`) edit what `parent` holds: is `parent` the isolated container, or
/// the parent of the single object isolated? Then the selection tools hit the art that the
/// clipping mask of `parent` hides, and the Unlock submenu lists it (#3748). The reference
/// app's help says that the parts outside a clipping mask can still be edited
/// (<https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-clipping-masks.html>).
fn edits_inside(parent: &Node, scope: Option<NodeId>) -> bool {
scope.is_some_and(|s| s == parent.id || parent.children().is_some_and(|c| c.iter().any(|c| c.id == s)))
}
fn hit_children(
parent: &Node,
p: Point,
@@ -670,8 +679,10 @@ fn hit_children(
chain.push(clip.id);
return Some(Hit { leaf: clip.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 });
}
// Elsewhere a clip group (or a layer with a clipping mask) only hits inside it.
if !clip_contains(clip, p) {
// Elsewhere a clip group (or a layer with a clipping mask) only hits inside it, except for
// the selection tools in isolation mode within it.
let edited = opt.clip_outlines && editable_only && edits_inside(parent, opt.scope);
if !edited && !clip_contains(clip, p) {
return None;
}
}
@@ -1205,7 +1216,9 @@ pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option<NodeId> {
/// out those on a locked artboard ([`crate::LockedArtboards::holds_id`]). Symbol instances are measured
/// by their art ([`Node::geometric_bounds_with`]). The art of a clip group or of a layer with a
/// clipping mask counts only when the marquee meets the region its clipping path clips to, and then
/// only within that region's bounds.
/// only within that region's bounds. In isolation mode the clipping mask of the isolated container,
/// or of the parent of the single object isolated, does not limit the marquee, as it does not limit
/// the selection tools' clicks.
pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) -> Vec<NodeId> {
marquee_with(doc, r, scope, leaves, false)
}
@@ -1223,16 +1236,15 @@ fn marquee_with(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool, wh
// Isolated art whose layer, or a group around it, was locked or hidden afterwards: the
// marquee touches none of it, as clicks don't (#5309).
Some(s) if !doc.is_editable(s.id) => vec![],
Some(s) => match (s.children(), marquee_in(s, r)) {
(Some(c), Some(r)) => c.iter().enumerate().filter(|(i, _)| !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(),
(Some(_), None) => vec![],
Some(s) => match s.children() {
Some(c) => c.iter().enumerate().filter(|(i, _)| !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(),
// A single object isolated: only it.
(None, _) => doc
None => doc
.parent_of(s.id)
.and_then(|p| doc.node(p))
.and_then(|p| Some((p.children()?, marquee_in(p, r)?)))
.and_then(|p| p.children())
.into_iter()
.flat_map(|(c, r)| c.iter().filter(|c| c.id == s.id).map(move |c| (c, r)))
.flat_map(|c| c.iter().filter(|c| c.id == s.id).map(|c| (c, r)))
.collect(),
},
// The objects of the layers, looking through sublayers (hidden, locked and template ones
+82 -3
View File
@@ -255,7 +255,9 @@ fn a_selected_objects_hidden_art_on_a_preview_off_layer_counts_on_its_outline()
/// #2345: a marquee touches a clip group only where its art shows. Over the clipped-away
/// half alone it selects nothing, and around a rectangle drawn beside the group's mask it
/// takes that rectangle alone; reaching into the mask takes the group. The same holds for
/// Group Selection's leaves and in isolation mode.
/// Group Selection's leaves. In isolation mode (#3748) the isolated group's own mask does not
/// limit the marquee, as it does not limit clicks: with the group or the art isolated, the
/// marquee takes the hidden art.
#[test]
fn a_marquee_leaves_a_clip_group_by_its_clipped_away_art() {
let mut d = Document::new(300.0, 200.0);
@@ -270,8 +272,9 @@ fn a_marquee_leaves_a_clip_group_by_its_clipped_away_art() {
let hidden = Rect::new(150.0, -10.0, 210.0, 50.0);
assert!(marquee(&d, hidden, None, false).is_empty());
assert!(marquee(&d, hidden, None, true).is_empty());
assert!(marquee(&d, hidden, Some(g), false).is_empty());
assert!(marquee(&d, hidden, Some(art_id), false).is_empty());
assert_eq!(marquee(&d, hidden, Some(g), false), vec![art_id]);
assert_eq!(marquee(&d, hidden, Some(g), true), vec![art_id]);
assert_eq!(marquee(&d, hidden, Some(art_id), false), vec![art_id]);
// This marquee surrounds the rectangle beside and crosses the hidden art's bottom edge.
assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside]);
// This marquee crosses the shown half's top edge and goes on into the hidden half.
@@ -682,6 +685,82 @@ fn select_behind_takes_a_layer_masks_outline_first() {
assert_eq!(objects_at(&d, Point::new(138.5, 90.0), HitOptions::default(), None), vec![art, clip]);
}
/// #3748: in isolation mode the selection tools hit the art that the isolated clip group (or
/// the isolated layer with a clipping mask) hides outside its mask, and the art when it is the
/// single object isolated; the mask's outline is still hit first. Outside isolation mode, and
/// for the tools that act on what is drawn, the hidden art is not hit. The Unlock submenu
/// lists the hidden art when it is locked.
#[test]
fn isolation_reaches_the_art_a_clipping_mask_hides() {
let hidden = Point::new(170.0, 90.0);
for layer in [false, true] {
let (mut d, g, clip, art) = masked_square(layer);
let isolated = HitOptions { scope: Some(g), ..SELECTING };
assert!(hit_test(&d, hidden, SELECTING).is_none(), "layer {layer}: not isolated");
let h = hit_test(&d, hidden, isolated);
assert_eq!(h.map(|h| (h.leaf, h.top_object(Some(g)))), Some((art, art)), "layer {layer}");
assert_eq!(objects_at(&d, hidden, SELECTING, Some(g)), vec![art], "layer {layer}");
assert_eq!(hit_test(&d, Point::new(140.0, 90.0), isolated).map(|h| h.leaf), Some(clip), "layer {layer}: the outline first");
let one = HitOptions { scope: Some(art), ..SELECTING };
assert_eq!(hit_test(&d, hidden, one).map(|h| h.leaf), Some(art), "layer {layer}: the art isolated");
assert!(hit_test(&d, hidden, HitOptions { scope: Some(g), ..HitOptions::default() }).is_none(), "layer {layer}: drawn art only");
assert!(hit_test_visible(&d, hidden, isolated).is_none(), "layer {layer}: sampling");
d.node_mut(art).unwrap().locked = true;
assert_eq!(locked_objects_at(&d, hidden, HitOptions { scope: Some(g), ..HitOptions::default() }), vec![art], "layer {layer}");
assert!(locked_objects_at(&d, hidden, HitOptions::default()).is_empty(), "layer {layer}: not isolated");
}
}
/// #3748, nested clip groups: with the outer clip group isolated, a click or a marquee takes
/// the art its own mask hides, while the mask of a clip group inside it still limits that
/// group's art. With the inner clip group isolated, or its art, the inner mask does not.
#[test]
fn isolation_reaches_hidden_art_only_for_the_isolated_clip_group() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let (outer, outer_clip, outer_art, inner, inner_clip, inner_art) =
(d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id());
let mask = |id, r| {
let mut path = Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::None, Paint::None, 0.0));
if let NodeKind::Path { clipping, .. } = &mut path.kind {
*clipping = true;
}
Arc::new(path)
};
let art = |id, r| Arc::new(Node::path(id, shapes::rectangle(r), Appearance::default_art()));
let inner_group = Node::new(
inner,
NodeKind::Group {
children: vec![mask(inner_clip, Rect::new(60.0, 60.0, 120.0, 120.0)), art(inner_art, Rect::new(40.0, 40.0, 160.0, 160.0))],
clip: true,
},
);
let children =
vec![mask(outer_clip, Rect::new(20.0, 20.0, 180.0, 180.0)), art(outer_art, Rect::new(190.0, 20.0, 260.0, 180.0)), Arc::new(inner_group)];
d.insert(Some(l), 0, Node::new(outer, NodeKind::Group { children, clip: true })).unwrap();
// `outer_hidden` is outside the outer mask, on the outer group's own art; `inner_hidden` is
// inside the outer mask and outside the inner one, on the inner group's art.
let (outer_hidden, inner_hidden, shown) = (Point::new(220.0, 100.0), Point::new(150.0, 100.0), Point::new(90.0, 90.0));
let leaf = |p, scope| hit_test(&d, p, HitOptions { scope, ..SELECTING }).map(|h| h.leaf);
assert_eq!((leaf(outer_hidden, None), leaf(inner_hidden, None)), (None, None), "not isolated");
assert_eq!(leaf(outer_hidden, Some(outer)), Some(outer_art), "outer isolated: its own hidden art");
assert_eq!(leaf(inner_hidden, Some(outer)), None, "outer isolated: the inner mask still clips");
let h = hit_test(&d, shown, HitOptions { scope: Some(outer), ..SELECTING });
assert_eq!(h.map(|h| (h.leaf, h.top_object(Some(outer)))), Some((inner_art, inner)), "outer isolated: shown art");
assert_eq!(leaf(inner_hidden, Some(inner)), Some(inner_art), "inner isolated");
assert_eq!(leaf(outer_hidden, Some(inner)), None, "inner isolated: the outer group's art");
assert_eq!(leaf(inner_hidden, Some(inner_art)), Some(inner_art), "the inner art isolated");
// These marquees cross the right edge of the outer art (outside the outer mask) and of the
// inner art (inside the outer mask, outside the inner one).
let (outer_edge, inner_edge) = (Rect::new(255.0, 95.0, 265.0, 105.0), Rect::new(155.0, 95.0, 165.0, 105.0));
assert_eq!(marquee(&d, outer_edge, Some(outer), false), vec![outer_art]);
assert!(marquee(&d, inner_edge, Some(outer), false).is_empty());
assert!(marquee(&d, inner_edge, Some(outer), true).is_empty());
assert_eq!(marquee(&d, inner_edge, Some(inner), false), vec![inner_art]);
assert!(marquee(&d, inner_edge, None, false).is_empty());
assert!(marquee(&d, outer_edge, None, false).is_empty());
}
/// Art in a sublayer is an object of its own: a click or a marquee takes it, not the
/// sublayer; hidden, locked and template sublayers block both.
#[test]
+6 -1
View File
@@ -26,7 +26,7 @@ pub use para_style::{
Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, LeadingModel, Mojikumi, ParaDirection,
ParaStyle, SingleWordJustify,
};
pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef};
pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef, TypeAntiAlias};
pub use tabs_lists::{
Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, LIST_LEVEL_INDENT, LIST_LEVELS, ListAlign, ListCounts, NumberStyle, ParaList, TabAlign,
TabStop,
@@ -86,6 +86,11 @@ pub struct TextObject {
/// Off for new type and for documents from before it, and saved only when on.
#[serde(default, rename = "opticalMarginAlignment", skip_serializing_if = "crate::skip::is_default")]
pub optical_margin: bool,
/// Character panel › Anti-aliasing Method: how the type's edges are drawn as pixels. Each
/// frame of a thread keeps its own method. Sharp for new type and for documents from before it,
/// and saved only when it is another method.
#[serde(default, rename = "antiAlias", skip_serializing_if = "crate::skip::is_default")]
pub anti_alias: TypeAntiAlias,
/// Character panel menu › System Layout, copied from the document: when on, each character
/// that takes space advances by a whole number of points. Off by default, and saved only when
/// on. The document setting is [`crate::DocSetup::system_layout`].
+2 -1
View File
@@ -1,7 +1,7 @@
use super::area::{AreaOptions, PathAlign, PathEffect};
use super::char_style::CharStyle;
use super::para_style::ParaStyle;
use super::runs::{RubySpan, TextKind, TextRun, move_anchors, reverse_from_end};
use super::runs::{RubySpan, TextKind, TextRun, TypeAntiAlias, move_anchors, reverse_from_end};
use super::tabs_lists::{ListCounts, ParaList};
use super::{Affine, PathData, Point, Rect, TextObject, TextStyleScale, Vec2};
@@ -90,6 +90,7 @@ impl TextObject {
path_spacing: 0.0,
wrap: Vec::new(),
optical_margin: false,
anti_alias: TypeAntiAlias::Sharp,
system_layout: false,
fractional_widths: true,
list_count: 0,
+38
View File
@@ -144,6 +144,44 @@ pub(super) fn reverse_from_end(path: &mut PathData) {
}
}
/// The anti-aliasing method of a type object (Character panel › Anti-aliasing Method): how the
/// edges of its characters are drawn when the type is drawn as pixels (the canvas, raster exports,
/// rasterizing). SVG, PDF and EPS export ignore it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TypeAntiAlias {
/// Hard edges: a pixel takes the type's color where the type covers at least half of it, and
/// nothing elsewhere.
None,
/// Each edge pixel shows the share of it the type covers.
#[default]
Sharp,
/// An edge pixel covered by a share `c` shows `2c - 0.5`, between 0 and 1: edges about half as
/// wide as Sharp's.
Crisp,
/// An edge pixel covered by a share `c` shows `2c`, at most 1: heavier type.
Strong,
}
impl TypeAntiAlias {
pub const ALL: [Self; 4] = [Self::None, Self::Sharp, Self::Crisp, Self::Strong];
/// The id used in command params and files (`none`, `sharp`, `crisp`, `strong`).
pub fn id(self) -> &'static str {
match self {
Self::None => "none",
Self::Sharp => "sharp",
Self::Crisp => "crisp",
Self::Strong => "strong",
}
}
/// The method an id names.
pub fn from_id(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|a| a.id() == id)
}
}
/// Where ruby (furigana) text sits: above its base text in horizontal type, right of it in
/// vertical type (the Japanese convention), or below (left in vertical type).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+171
View File
@@ -210,6 +210,177 @@ fn baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bo
assert_eq!(baked(&fitted, distort()).1.geom, None);
}
#[test]
fn baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = |kind| GradientPaint::new(Gradient::new(kind, stops.clone()));
// A ramp across the square and one down it, so both axes of the box map show.
let across = GradientGeom { start: Point::new(20.0, 30.0), end: Point::new(80.0, 30.0), aspect: 1.0, focal: None };
let down = GradientGeom { start: Point::new(60.0, 10.0), end: Point::new(60.0, 75.0), aspect: 1.0, focal: None };
let placed = |geom| Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp(GradientKind::Linear) }));
let fitted = Paint::Gradient(Box::new(ramp(GradientKind::Linear)));
let square = Rect::new(0.0, 0.0, 100.0, 100.0);
let baked = |fill: &Paint, e: Effect| {
let mut n = Node::path(NodeId(50), shapes::rectangle(square), Appearance::basic(fill.clone(), Paint::None, 0.0));
n.appearance.effects.push(e);
let d = bake_document(&doc_with(n)).unwrap();
let m = d.node(NodeId(50)).unwrap();
match m.appearance.fill_paint() {
Paint::Gradient(g) => (m.geometric_bounds().unwrap(), (*g).clone()),
p => panic!("a gradient: {p:?}"),
}
};
// Where the box map from the square to `to` takes `p`.
let follow = |p: Point, to: Rect| Point::new(to.x0 + p.x / 100.0 * to.width(), to.y0 + p.y / 100.0 * to.height());
let styles = [
"arc",
"arcLower",
"arcUpper",
"arch",
"bulge",
"shellLower",
"shellUpper",
"flag",
"wave",
"fish",
"rise",
"fisheye",
"inflate",
"squeeze",
"twist",
];
// The square's warped bounds, from `warp_point` on 401 points along each side: the square's
// corners are (-1, -1) and (1, 1) in the warp's box coordinates, and a vertical warp swaps
// the axes.
let warped_bounds = |style: &str, p: &serde_json::Value| {
let Some(style) = crate::WarpStyle::from_id(style) else { panic!("warp.{style}: a style") };
let share = |key: &str, default: f64| p.get(key).and_then(serde_json::Value::as_f64).unwrap_or(default) / 100.0;
let vertical = p.get("orientation").and_then(serde_json::Value::as_str) == Some("vertical");
let (bend, dh, dv) = (share("bend", 50.0), share("horizontal", 0.0), share("vertical", 0.0));
let mut r: Option<Rect> = None;
for k in 0..=400 {
let s = f64::from(k) / 200.0 - 1.0;
for (x, y) in [(s, -1.0), (s, 1.0), (-1.0, s), (1.0, s)] {
let (x2, y2) = if vertical {
let (a, b) = crate::warp_point(style, bend, dh, dv, 1.0, y, x);
(b, a)
} else {
crate::warp_point(style, bend, dh, dv, 1.0, x, y)
};
let q = Point::new(50.0 + x2 * 50.0, 50.0 + y2 * 50.0);
r = Some(r.map_or(Rect::from_points(q, q), |r| r.union_pt(q)));
}
}
r.unwrap_or(square)
};
let near =
|a: Rect, b: Rect, tol: f64| (a.x0 - b.x0).abs() < tol && (a.y0 - b.y0).abs() < tol && (a.x1 - b.x1).abs() < tol && (a.y1 - b.y1).abs() < tol;
let mut moved = 0;
for style in styles {
for params in
[json!({"bend": 30.0}), json!({"bend": -40.0, "orientation": "vertical"}), json!({"bend": 20.0, "horizontal": 30.0, "vertical": -20.0})]
{
// A warp that bends the square's sides inward keeps its bounds (within 0.5 pt), and the
// gradient stays with them.
let expected = warped_bounds(style, &params);
let moves = !near(expected, square, 0.5);
moved += usize::from(moves);
for linear in [across, down] {
let what = format!("warp.{style} {params} {linear:?}");
let (b, g) = baked(&placed(linear), fx(&format!("warp.{style}"), params.clone()));
assert!(near(b, expected, 0.1), "{what}: bounds {b:?}, want {expected:?}");
let geom = g.geom.unwrap();
let shifted = geom.start.distance(linear.start).max(geom.end.distance(linear.end));
assert_eq!(shifted > 0.1, moves, "{what}: the gradient moves {shifted} with bounds {b:?}");
let want = GradientGeom { start: follow(linear.start, b), end: follow(linear.end, b), ..linear };
assert_geom_near(geom, want, &what);
}
let what = format!("warp.{style} {params}");
// A fitted gradient stays fitted, and fits the warped outline.
assert_eq!(baked(&fitted, fx(&format!("warp.{style}"), params)).1.geom, None, "{what}");
}
}
assert_eq!(moved, 39, "the warps that move the bounds, of 45");
// With no bend and no distortion the gradient stays where it is.
let (_, g) = baked(&placed(across), fx("warp.arc", json!({"bend": 0.0})));
assert_geom_near(g.geom.unwrap(), across, "no bend");
}
/// A warp with no distortion and no bend (or a bend Arc takes as none) leaves a placed gradient
/// exactly where it was, even where warping the outline moves its bounds by a rounding error.
#[test]
fn a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let geom = GradientGeom { start: Point::new(13.7, 31.3), end: Point::new(91.1, 47.9), aspect: 1.0, focal: None };
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) }));
let mut drift = 0;
let cases = crate::WARP_STYLES.map(|(s, _)| (s, 0.0));
for (k, (style, bend)) in cases.into_iter().chain([("arc", 1e-5), ("arc", -1e-5)]).enumerate() {
let k = k as f64;
let oval = Rect::new(10.1 + k * 0.37, 20.3 - k * 1.13, 97.7 + k * 3.31, 71.9 + k * 0.71);
let mut n = Node::path(NodeId(50), shapes::ellipse(oval), Appearance::basic(placed.clone(), Paint::None, 0.0));
n.appearance.effects.push(fx(&format!("warp.{style}"), json!({ "bend": bend })));
let d = bake_document(&doc_with(n)).unwrap();
let m = d.node(NodeId(50)).unwrap();
drift += usize::from(m.geometric_bounds() != shapes::ellipse(oval).bounds());
let Paint::Gradient(g) = m.appearance.fill_paint() else { panic!("warp.{style} {bend}: a gradient") };
assert_eq!(g.geom, Some(geom), "warp.{style} {bend}");
}
assert!(drift > 0, "the warped outlines' bounds move by a rounding error");
// Another style with as small a bend still moves the outline, and the gradient follows it.
let mut n = Node::path(NodeId(50), shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(placed, Paint::None, 0.0));
n.appearance.effects.push(fx("warp.rise", json!({"bend": 1e-5})));
let d = bake_document(&doc_with(n)).unwrap();
let Paint::Gradient(g) = d.node(NodeId(50)).unwrap().appearance.fill_paint() else { panic!("warp.rise: a gradient") };
assert_ne!(g.geom, Some(geom), "warp.rise 1e-5");
}
/// A Warp effect on a fill or a stroke itself moves and scales its placed gradient from the
/// path's bounds before the warp to its bounds after it (a stroke: its stroke-inflated bounds).
#[test]
fn a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
let geom = GradientGeom { start: Point::new(20.0, 60.0), end: Point::new(80.0, 60.0), aspect: 1.0, focal: None };
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
let square = Rect::new(0.0, 0.0, 100.0, 100.0);
let width = 4.0;
// Where the box map from `from` to `to` takes `p`.
let follow = |p: Point, from: Rect, to: Rect| {
Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height())
};
let pad = |r: Rect| r.inflate(width / 2.0, width / 2.0);
for style in ["arc", "rise", "inflate"] {
for stroke in [false, true] {
let what = format!("warp.{style} on the {}", if stroke { "stroke" } else { "fill" });
let mut ap =
if stroke { Appearance::basic(Paint::None, placed.clone(), width) } else { Appearance::basic(placed.clone(), Paint::None, 0.0) };
let Some(item) = ap.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_)) == stroke) else { panic!("{what}: the item") };
item.effects_mut().push(fx(&format!("warp.{style}"), json!({"bend": 40.0})));
let n = Node::path(NodeId(50), shapes::rectangle(square), ap);
let d = bake_document(&doc_with(n)).unwrap();
let mut found = vec![];
d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| {
for item in &c.appearance.items {
if let Paint::Gradient(g) = item.paint() {
found.push((c.geometric_bounds().unwrap(), g.geom.unwrap()));
}
}
});
assert!(!found.is_empty(), "{what}: the gradient is kept");
for (b, g) in found {
assert_ne!(b, square, "{what}: the warp changes the bounds");
let (from, to) = if stroke { (pad(square), pad(b)) } else { (square, b) };
let want = GradientGeom { start: follow(geom.start, from, to), end: follow(geom.end, from, to), ..geom };
assert_geom_near(g, want, &what);
}
}
}
}
#[test]
fn item_effects_split_into_one_path_per_item() {
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
+1 -2
View File
@@ -111,8 +111,7 @@ fn lengths_of(id: &str) -> Lengths {
}
}
const WARP_DOC: &str =
"{bend: % (-100..100, 50), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}";
const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}";
const DT: &[&str] = &["Effect", "Distort & Transform"];
+15 -6
View File
@@ -175,9 +175,10 @@ pub fn apply_geometry_bez_carrying(effects: &[Effect], path: &BezPath, bounds: R
}
/// How placed gradients follow geometry effects. The Transform effect without copies maps them as
/// it maps the object (with copies, `crate::reshape` gives each copy its own). Free Distort moves
/// and scales them from the object's bounds before the effect to its bounds after it. For the
/// other geometry effects it is empty. It does not change gradients fitted to the object.
/// it maps the object (with copies, `crate::reshape` gives each copy its own). Free Distort and
/// the Warp effects move and scale them from the object's bounds before the effect to its bounds
/// after it. For the other geometry effects it is empty. It does not change gradients fitted to
/// the object.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Carry(Vec<CarryStep>);
@@ -193,9 +194,10 @@ enum CarryStep {
impl Carry {
/// Does effect `id` with merged params `p` on geometry with reference box `b` (`seed` for
/// Random) move placed gradients? Free Distort does unless it leaves every corner in place;
/// the Transform effect does without copies (with copies the original stays where it is, and
/// `crate::reshape` gives each copy its own gradients).
/// Random) move placed gradients? Free Distort does unless it leaves every corner in place, a
/// Warp effect unless it has no bend and no distortion, and the Transform effect does without
/// copies (with copies the original stays where it is, and `crate::reshape` gives each copy
/// its own gradients).
pub(crate) fn follows(id: &str, p: &Value, b: Rect, seed: u64) -> bool {
match id {
"distort.transform" => {
@@ -209,10 +211,17 @@ impl Carry {
[(b.x0, b.y0), (b.x1, b.y0), (b.x1, b.y1), (b.x0, b.y1)].iter().zip(&corners).all(|(a, c)| c.distance((*a).into()) <= tol);
!still && corners.iter().all(|c| c.x.is_finite() && c.y.is_finite())
}
_ if id.starts_with("warp.") => crate::warp::bends(id, p),
_ => false,
}
}
/// Does effect `id` move placed gradients from each path's bounds before it to its bounds
/// after it, rather than by one map for the whole object?
pub(crate) fn by_bounds(id: &str) -> bool {
id == "distort.freeDistort" || id.starts_with("warp.")
}
/// How placed gradients follow effect `id` (as [`Self::follows`]) on an object whose geometric
/// bounds are `from` before the effect and `to` after it; empty when they stay in place.
pub(crate) fn of(id: &str, p: &Value, b: Rect, from: Option<Rect>, to: Option<Rect>, seed: u64) -> Carry {
+148
View File
@@ -349,6 +349,42 @@ fn shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform() {
assert!((out.geometric_bounds().unwrap().x0 - 30.0).abs() < 1e-6, "the art moves");
}
/// Under a Warp effect, the gradient placed on a symbol instance's art's own fill follows the art's
/// bounds, and the gradient placed on a compound shape follows its area's bounds, each from before
/// the warp to after it, in the art Pathfinder takes.
#[test]
fn shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
let geom = GradientGeom { start: (2.0, 3.0).into(), end: (9.0, 3.0).into(), aspect: 1.0, focal: None };
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
let follow = |p: Point, from: Rect, to: Rect| {
Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height())
};
let check = |out: &Node, from: Rect, what: &str| {
let to = out.geometric_bounds().unwrap();
assert_ne!(from, to, "{what}: the warp changes the bounds");
let Paint::Gradient(g) = out.appearance.fill_paint() else { panic!("{what}: a gradient: {:?}", out.appearance) };
let got = g.geom.unwrap();
let (s, e) = (follow(geom.start, from, to), follow(geom.end, from, to));
assert!(got.start.distance(s) < 1e-6 && got.end.distance(e) < 1e-6, "{what}: {got:?}, want {s:?} {e:?}");
};
for style in ["arc", "rise", "bulge"] {
let warp = || Effect { id: format!("warp.{style}"), params: serde_json::json!({"bend": 40.0}), visible: true };
let mut art = Node::group(NodeId(20), vec![sq(21, 0.0, Color::BLACK)]);
art.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed.clone())));
let hook = move |_: &Node| Some(art.clone());
let mut instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY });
instance.appearance.effects.push(warp());
check(&shape_art(&instance, Some(&hook)).unwrap(), Rect::new(0.0, 0.0, 10.0, 10.0), &format!("warp.{style} on a symbol instance"));
let mut shape = compound_shape(ShapeMode::Add);
shape.appearance = Appearance::basic(placed.clone(), Paint::None, 0.0).into();
shape.appearance.effects.push(warp());
check(&shape_art(&shape, None).unwrap(), Rect::new(0.0, 0.0, 15.0, 10.0), &format!("warp.{style} on a compound shape"));
}
}
#[test]
fn a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
@@ -378,6 +414,118 @@ fn a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths()
assert!(near(s, Point::new(0.0, 5.0)) && near(e, Point::new(20.0, 5.0)), "{s:?} {e:?}");
}
/// A group's Warp effect moves and scales the gradient placed on each of its paths from that path's
/// bounds before the warp to its bounds after it, not by the group's bounds.
#[test]
fn a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
let geom = |x: f64| GradientGeom { start: (x + 2.0, 3.0).into(), end: (x + 8.0, 3.0).into(), aspect: 1.0, focal: None };
let placed = |x: f64| Paint::Gradient(Box::new(GradientPaint { geom: Some(geom(x)), ..ramp.clone() }));
let square = |id: u64, x: f64| {
Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::basic(placed(x), Paint::None, 0.0)))
};
for style in ["arc", "bulge", "rise", "twist"] {
let mut g = Node::group(NodeId(10), vec![square(1, 0.0), square(2, 20.0)]);
g.appearance.effects.push(Effect { id: format!("warp.{style}"), params: serde_json::json!({"bend": 40.0}), visible: true });
let art = evaluate_container(None, &g).unwrap();
let mut moved = false;
for (i, x) in [0.0, 20.0].into_iter().enumerate() {
let m = &art.children().unwrap()[i];
let b = m.geometric_bounds().unwrap();
let follow = |p: Point| Point::new(b.x0 + (p.x - x) / 10.0 * b.width(), b.y0 + p.y / 10.0 * b.height());
let Paint::Gradient(gp) = m.appearance.fill_paint() else { panic!("a gradient") };
let got = gp.geom.unwrap();
let want = geom(x);
assert!(
got.start.distance(follow(want.start)) < 1e-6 && got.end.distance(follow(want.end)) < 1e-6,
"warp.{style} path {i}: {got:?} in {b:?}"
);
moved |= got.start.distance(want.start) > 0.1;
}
assert!(moved, "warp.{style} moves a gradient");
}
}
/// Under a group's Warp effect, the group's own placed gradients follow the group's bounds, a
/// nested group's own placed gradients follow the nested group's bounds, and a compound path's
/// placed gradients follow the compound path's bounds, each from before the warp to after it (a
/// stroke's: those bounds inflated by the stroke).
#[test]
fn a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
let geom = GradientGeom { start: (12.0, 4.0).into(), end: (26.0, 4.0).into(), aspect: 1.0, focal: None };
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
let own_fill = |n: &mut Node| {
n.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed.clone())));
n.appearance.items.push(AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(placed.clone(), 3.0)));
};
let follow = |p: Point, from: Rect, to: Rect| {
Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height())
};
for style in ["arc", "bulge", "rise", "flag"] {
let mut inner = Node::group(NodeId(5), vec![sq(1, 0.0, Color::BLACK)]);
own_fill(&mut inner);
let ring = vec![
Arc::new(Node::path(NodeId(6), shapes::rectangle(Rect::new(20.0, 0.0, 30.0, 14.0)), Appearance::default())),
Arc::new(Node::path(NodeId(7), shapes::rectangle(Rect::new(23.0, 3.0, 27.0, 7.0)), Appearance::default())),
];
let mut compound = Node::new(NodeId(3), NodeKind::Compound { children: ring, rule: FillRule::EvenOdd });
compound.appearance = Appearance::basic(placed.clone(), placed.clone(), 2.0).into();
let mut g = Node::group(NodeId(10), vec![Arc::new(inner), Arc::new(compound), sq(2, 40.0, Color::BLACK)]);
own_fill(&mut g);
let mut before = std::collections::HashMap::new();
g.walk(&mut |c: &Node| {
before.insert(c.id, c.geometric_bounds().unwrap());
});
g.appearance.effects.push(Effect { id: format!("warp.{style}"), params: serde_json::json!({"bend": 40.0}), visible: true });
let art = evaluate_container(None, &g).unwrap();
// The bounds after the warp of the group, the nested group and the compound path (the paths
// a group's own fill paints carry the group's id).
let mut after = std::collections::HashMap::new();
art.walk(&mut |c: &Node| {
if matches!(c.kind, NodeKind::Group { .. } | NodeKind::Compound { .. }) {
after.entry(c.id).or_insert_with(|| c.geometric_bounds().unwrap());
}
});
let mut checked = vec![];
art.walk(&mut |c: &Node| {
if ![NodeId(3), NodeId(5), NodeId(10)].contains(&c.id) {
return;
}
for item in &c.appearance.items {
let Paint::Gradient(gp) = item.paint() else { continue };
let (from, to) = (before[&c.id], after[&c.id]);
assert_ne!(from, to, "warp.{style} {:?}: the warp changes the bounds", c.id);
let stroke = match item {
AppearanceItem::Stroke(s) => Some(s),
AppearanceItem::Fill(_) => None,
};
let (from, to) = match stroke {
Some(s) => (s.paint_bounds(from), s.paint_bounds(to)),
None => (from, to),
};
let got = gp.geom.unwrap();
let (s, e) = (follow(geom.start, from, to), follow(geom.end, from, to));
assert!(
got.start.distance(s) < 1e-6 && got.end.distance(e) < 1e-6,
"warp.{style} {:?} (stroke: {}): {got:?}, want {s:?} {e:?} ({from:?} to {to:?})",
c.id,
stroke.is_some()
);
checked.push((c.id, stroke.is_some()));
}
});
checked.sort();
checked.dedup();
let want: Vec<_> = [NodeId(3), NodeId(5), NodeId(10)].into_iter().flat_map(|id| [(id, false), (id, true)]).collect();
assert_eq!(checked, want, "warp.{style}");
}
}
/// A group nested in a group with a Transform effect or Free Distort keeps its own placed gradient
/// on its members: the Transform's map moves it, and Free Distort moves and scales it from the
/// nested group's bounds before the effect to its bounds after it.
+8 -7
View File
@@ -658,12 +658,13 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
for e in fx {
let Some(bounds) = art.geometric_bounds() else { break };
let params = merged_params(&e.id, &e.params);
// Placed gradients follow a Transform effect and Free Distort ([`crate::Carry`]): under
// Free Distort (`boxes`) each path's move and scale from its bounds before the effect to
// its bounds after it. Under that and the other effects, the placed gradients of type's
// outlines in `art` (`outline_text`) move and scale with the outlines' bounds.
// Placed gradients follow a Transform effect, Free Distort and the Warp effects
// ([`crate::Carry`]): under Free Distort and Warp (`boxes`) each path's move and scale from
// its bounds before the effect to its bounds after it. Under those and the other effects,
// the placed gradients of type's outlines in `art` (`outline_text`) move and scale with
// the outlines' bounds.
let follows = crate::Carry::follows(&e.id, &params, bounds, seed);
let boxes = follows && e.id == "distort.freeDistort";
let boxes = follows && crate::Carry::by_bounds(&e.id);
let mut outlines = vec![];
if !follows || boxes {
type_outline_bounds(art, &mut outlines);
@@ -734,8 +735,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
rebase_type_outlines(art, &mut outlines.into_iter());
}
// What paints the art as a whole, and the own fills and strokes of the groups inside it,
// follow their bounds under Free Distort (they stay when no path changed them). Each stays
// when a path under it that its bounds measure kept its shape (past the budget).
// follow their bounds under Free Distort and Warp (they stay when no path changed them).
// Each stays when a path under it that its bounds measure kept its shape (past the budget).
let mut after = vec![];
let held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, &mut after).1;
if !groups.is_empty() {
+18
View File
@@ -180,6 +180,24 @@ fn warp_zero_bend_is_identity_ish() {
}
}
/// `warp::bends` says a warp moves placed gradients exactly when the warp moves the outline:
/// Arc leaves the outline as it is under a bend of 0.0001 % and moves it above that, and the other
/// styles move it at any bend.
#[test]
fn warp_bends_when_the_warp_moves_the_outline() {
// A star: Fisheye moves nothing on the edge of the box, where an ellipse or a rectangle lies.
let oval = shapes::star(Point::new(51.7, 34.3), 40.9, 17.3, 5, 7.0);
for (s, _) in WARP_STYLES {
let id = format!("warp.{s}");
let flat = run(&id, json!({"bend": 0}), &oval);
for bend in [0.0, 1e-5, -1e-5, 0.9e-4, -0.9e-4, 1.1e-4, -1.1e-4] {
let p = json!({ "bend": bend });
let moved = run(&id, p.clone(), &oval) != flat;
assert_eq!(crate::warp::bends(&id, &p), moved, "{id} at {bend} %");
}
}
}
#[test]
fn warp_arc_bends_the_top_edge() {
let out = run("warp.arc", json!({"bend": 50}), &square());
+11
View File
@@ -32,3 +32,14 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) -
let piece = b.width().hypot(b.height()) / 24.0;
map_nonlinear_within(path, piece, max, f)
}
/// Does warp effect `id` with params `p` move any point? Every style leaves the box as it is with
/// no bend and no distortion, and Arc (`warp.arc`) takes a bend under 0.0001 % as none: the
/// threshold must match the one in [`warp_point`]'s Arc case (`warp_bends_when_the_warp_moves_the_outline`
/// tests both sides of it).
pub(crate) fn bends(id: &str, p: &Value) -> bool {
let share = |key: &str, default: f64| num(p, key, default).clamp(-100.0, 100.0) / 100.0;
let bend = share("bend", 50.0);
let bent = if id == "warp.arc" { bend.abs() >= 1e-6 } else { bend != 0.0 };
bent || share("horizontal", 0.0) != 0.0 || share("vertical", 0.0) != 0.0
}
+9 -1
View File
@@ -356,8 +356,16 @@ pub fn command_specs() -> &'static [CommandSpec] {
})
}
/// The command `id`: a built-in one, or a command an installed plug-in adds
/// ([`plugin::command_specs`]).
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
command_specs().iter().find(|c| c.id == id)
command_specs().iter().find(|c| c.id == id).or_else(|| plugin::find_spec(id))
}
/// Runs command `spec`'s implementation with `p`. A plug-in command's runs the plug-in
/// ([`plugin::run_command`]), which needs the command's id.
pub(crate) fn run_spec(s: &mut Session, spec: &CommandSpec, p: &Value) -> Result<Value> {
if plugin::is_command_id(spec.id) { plugin::run_command(s, spec.id, p) } else { (spec.run)(s, p) }
}
// ---------- param helpers ----------
+47 -27
View File
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add New Fill",
["Window", "Appearance"],
Some("Cmd+/"),
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
has_doc,
|s, p| add_item(s, p, true)
),
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add New Stroke",
["Window", "Appearance"],
Some("Cmd+Alt+/"),
"{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
"{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
has_doc,
|s, p| add_item(s, p, false)
),
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Clear Appearance",
["Window", "Appearance"],
None,
"{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
"{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md)",
has_doc,
clear_appearance
),
@@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reduce to Basic Appearance",
["Window", "Appearance"],
None,
"{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
"{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
has_doc,
reduce_basic
),
@@ -81,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Appearance Item",
[],
None,
"{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
"{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
has_doc,
set_item
),
@@ -90,7 +90,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Remove Item",
[],
None,
"{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
"{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
has_doc,
remove_item
),
@@ -108,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Duplicate Item",
["Window", "Appearance"],
None,
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
has_doc,
duplicate_item
),
@@ -117,7 +117,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reorder Appearance Item",
[],
None,
"{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}",
"{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}",
has_doc,
move_item
),
@@ -126,7 +126,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Eyedropper",
[],
None,
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's solid fill and stroke for this instance only, as the Eyedropper Options allow, and a sampled gradient or pattern, a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}",
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's solid fill and stroke for this instance only, as the Eyedropper Options allow, and a sampled gradient or pattern, a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {ids}",
has_doc,
copy_from
),
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Show All Hidden Attributes",
["Window", "Appearance"],
None,
"{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}",
"{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}",
has_doc,
show_all_hidden
),
@@ -162,7 +162,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Appearance",
[],
None,
"{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {source, target}",
"{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {source, target}",
has_doc,
transfer
),
@@ -628,25 +628,33 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
}
let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids));
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))
let object = super::graph::capture_series_object_opacity(d, &ids);
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || object))
})?;
s.active_appearance_item = None;
ok()
}
/// Each of `ids` whose object opacity or blend mode is another in `d` than in `before`, for
/// [`super::graph::capture_series_object_opacity`].
fn object_opacity_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<NodeId> {
let of = |d: &vectorcraft_doc::Document, id: NodeId| d.node(id).map(|n| (n.opacity, n.blend));
ids.iter().copied().filter(|id| of(before, *id) != of(d, *id)).collect()
}
/// Each of `ids` with both kinds of row (fill, then stroke), for [`super::graph::capture_series_opacity`] after an
/// edit that replaces whole stacks.
fn both_kinds(ids: &[NodeId]) -> Vec<(NodeId, bool)> {
ids.iter().flat_map(|id| [(*id, true), (*id, false)]).collect()
}
/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost visible row has another opacity in `d`
/// than in `before` (no row counts as opaque), for [`super::graph::capture_series_opacity`].
/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost visible row has another opacity or blend
/// mode in `d` than in `before` (no row counts as opaque and Normal), for [`super::graph::capture_series_opacity`].
fn top_opacity_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<(NodeId, bool)> {
let top = |d: &vectorcraft_doc::Document, id: NodeId, fill: bool| {
d.node(id)
.and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill && it.visible()).map(AppearanceItem::opacity))
.unwrap_or(1.0)
.and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill && it.visible()).map(|it| (it.opacity(), it.blend())))
.unwrap_or((1.0, BlendMode::Normal))
};
ids.iter().flat_map(|id| [(*id, true), (*id, false)]).filter(|(id, fill)| top(before, *id, *fill) != top(d, *id, *fill)).collect()
}
@@ -666,9 +674,10 @@ fn painted_rows(d: &vectorcraft_doc::Document, rows: impl IntoIterator<Item = (N
}
/// Store on the graph what an edit of `ids` (from `before` to `d`) leaves on the topmost visible fill and stroke of the
/// series marks among them: a paint, weight or opacity that changed, and the paint (`paints`) and opacity (`opacities`) of
/// the kinds of row the edit wrote, as (fill, stroke). With `top_only` the edit wrote the topmost row of each kind, which
/// counts only when it is visible. Returns whether the stored paint changed.
/// series marks among them: a paint, weight, opacity or blend mode that changed, and the paint (`paints`) and opacity and
/// blend mode (`opacities`) of the kinds of row the edit wrote, as (fill, stroke). With `top_only` the edit wrote the
/// topmost row of each kind, which counts only when it is visible. It also stores the marks' object opacity and blend mode
/// when they changed, or on every mark when the edit wrote them (`object`). Returns whether the stored paint changed.
pub(super) fn capture_series_writes(
before: &vectorcraft_doc::Document,
d: &mut vectorcraft_doc::Document,
@@ -676,22 +685,29 @@ pub(super) fn capture_series_writes(
paints: (bool, bool),
opacities: (bool, bool),
top_only: bool,
object: bool,
) -> bool {
let top_shows = |d: &vectorcraft_doc::Document, id: NodeId, kind: bool| {
let items = d.node(id).map_or(&[][..], |n| &n.appearance.items[..]);
items.iter().rev().find(|it| it.is_fill() == kind).is_some_and(AppearanceItem::visible)
};
let written = |d: &vectorcraft_doc::Document, (fill, stroke): (bool, bool)| -> Vec<(NodeId, bool)> {
let kinds = |(fill, stroke): (bool, bool)| -> Vec<(NodeId, bool)> {
let rows = ids.iter().flat_map(|id| [fill.then_some((*id, true)), stroke.then_some((*id, false))]).flatten();
painted_rows(d, rows.filter(|(id, kind)| !top_only || top_shows(d, *id, *kind)))
rows.filter(|(id, kind)| !top_only || top_shows(d, *id, *kind)).collect()
};
let written = |d: &vectorcraft_doc::Document, rows: (bool, bool)| painted_rows(d, kinds(rows));
let mut opacity_rows = top_opacity_changed(before, d, ids);
// A written row that had paint before the edit counts too when the edit left it without paint: only a row without
// paint both before and after keeps what the graph stores.
opacity_rows.extend(painted_rows(before, kinds(opacities)));
opacity_rows.extend(written(d, opacities));
let mut rows = top_paint_changed(before, d, ids);
rows.extend(written(d, paints));
let kept = super::graph::capture_series_opacity(d, &opacity_rows);
let painted = super::graph::capture_series_top_paints(d, &rows);
kept || painted
let objects = if object { ids.to_vec() } else { object_opacity_changed(before, d, ids) };
let stored = super::graph::capture_series_object_opacity(d, &objects);
kept || painted || stored
}
/// The kind of the row at `at` (fill `true`, stroke `false`) when it is the topmost visible row of that kind in `ap`.
@@ -743,7 +759,8 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
let mut rows = top_paint_changed(&before, d, &changed);
rows.extend(painted_rows(d, both_kinds(&[target])));
let painted = super::graph::capture_series_top_paints(d, &rows);
Ok(count_changed(d, &before, &[source, target]) + usize::from(kept || painted))
let object = super::graph::capture_series_object_opacity(d, &changed);
Ok(count_changed(d, &before, &[source, target]) + usize::from(kept || painted || object))
})?;
s.active_appearance_item = None;
Ok(json!({ "source": source.0, "target": target.0 }))
@@ -862,10 +879,10 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
edited.push((*id, fill));
}
}
// The graph keeps an opacity, paint or weight set on the topmost visible fill or stroke of its series art for when
// it is drawn again, and hiding or showing a row changes which row that is.
// The graph keeps an opacity, blend mode, paint or weight set on the topmost visible fill or stroke of its series art
// for when it is drawn again, and hiding or showing a row changes which row that is.
let mut opacities = top_opacity_changed(&before, d, &ids);
if p.get("opacity").and_then(Value::as_f64).is_some() {
if p.get("opacity").and_then(Value::as_f64).is_some() || blend.is_some() {
opacities.extend(edited.iter().copied());
}
let kept = super::graph::capture_series_opacity(d, &opacities);
@@ -1347,7 +1364,10 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
rows.extend(painted_rows(d, written(paints)));
let kept = super::graph::capture_series_opacity(d, &opacity_rows);
let painted = super::graph::capture_series_top_paints(d, &rows);
Ok(count_changed(d, &before, &targets) + usize::from(kept || painted))
// A pick of the object's transparency writes it on every target.
let objects = if attrs.appearance.transparency { targets.clone() } else { object_opacity_changed(&before, d, &targets) };
let object = super::graph::capture_series_object_opacity(d, &objects);
Ok(count_changed(d, &before, &targets) + usize::from(kept || painted || object))
})?;
Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
+2 -2
View File
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Image Trace"],
None,
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}",
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}",
has_selection_or_ids,
|s, p| trace_make(s, p, false)
),
@@ -153,7 +153,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make and Expand",
["Object", "Image Trace"],
None,
"{…as imageTrace.make, byColor?: false (each color's paths in a group of their own, as imageTrace.expand)} same as imageTrace.make, then expand → {id, paths, anchors, colors}",
"{…as imageTrace.make, byColor?: false (each color's paths in a group of their own, as imageTrace.expand)} same as imageTrace.make, then expand → {id, paths, anchors, colors, warnings: [text…] (as imageTrace.make)}",
has_selection_or_ids,
|s, p| trace_make(s, p, true)
),
+45 -2
View File
@@ -712,7 +712,8 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Val
_ => trace_now(&inputs, &trace, &bytes, &|_, _| true)?,
};
let TraceInputs { target, image: img, .. } = inputs;
let warnings = trace_warnings((img.width, img.height), raster, res.worked_at);
let mut warnings = keep_warnings(&trace, &res);
warnings.extend(trace_warnings((img.width, img.height), raster, res.worked_at));
// Pixel space of the decoded raster → the image object's pixel space → document.
let sx = img.width.max(1) as f64 / raster.0.max(1) as f64;
let sy = img.height.max(1) as f64 / raster.1.max(1) as f64;
@@ -824,6 +825,30 @@ pub(crate) fn trace_warnings(size: (u32, u32), traced: (u32, u32), worked: Optio
out
}
/// What a trace with Keep Colors tells the user: that nothing was traced because no color the
/// trace found matches a listed one, because Ignore Color left out the ones that match, or because
/// the ones that match produce no paths; each listed color that matches none of them; and that Flat
/// Logo mode does not use the list.
pub(crate) fn keep_warnings(params: &tr::TraceParams, res: &tr::TraceResult) -> Vec<String> {
if params.keep_colors.is_empty() {
return vec![];
}
if params.mode == tr::Mode::Logo {
return vec!["Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds.".to_string()];
}
if res.paths.is_empty() && res.keep_unmatched.len() == params.keep_colors.len() {
return vec![format!("Nothing was traced: none of the colors the trace found matches keepColors ({}).", params.keep_colors.join(", "))];
}
let mut out = vec![];
if res.paths.is_empty() && res.keep_ignored {
out.push("Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists.".to_string());
} else if res.paths.is_empty() {
out.push("Nothing was traced: the colors that match keepColors produce no paths.".to_string());
}
out.extend(res.keep_unmatched.iter().map(|c| format!("keepColors: none of the colors the trace found matches {c}, so nothing of it is traced.")));
out
}
fn trace_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let d = &s.doc()?.doc;
let v: Vec<NodeId> = root_ids(s, p)?.into_iter().filter(|i| d.node(*i).is_some_and(is_trace_group)).collect();
@@ -1069,7 +1094,25 @@ mod color_group_tests {
#[cfg(test)]
mod trace_limit_tests {
use super::{MAX_TRACE_ANCHORS, trace_warnings};
use super::{MAX_TRACE_ANCHORS, keep_warnings, trace_warnings};
use vectorcraft_trace as tr;
#[test]
fn keep_warnings_name_matched_colors_that_produce_no_paths() {
let params = tr::TraceParams { keep_colors: vec!["#ff0000".into()], ..tr::TraceParams::default() };
// A listed color was traced, and produced no paths: the palette lists no color without paths.
let traced = tr::TraceResult::default();
let none = vec!["Nothing was traced: the colors that match keepColors produce no paths.".to_string()];
assert_eq!(keep_warnings(&params, &traced), none);
// With Ignore Color, too: Ignore Color did not leave that color out.
let ignoring = tr::TraceParams { ignore_white: true, ..params.clone() };
assert_eq!(keep_warnings(&ignoring, &traced), none);
// Ignore Color left out every color that matches: none was traced.
assert_eq!(
keep_warnings(&ignoring, &tr::TraceResult { keep_ignored: true, ..tr::TraceResult::default() }),
vec!["Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists.".to_string()]
);
}
#[test]
fn trace_warnings_name_a_large_or_scaled_down_image() {
+22 -9
View File
@@ -157,7 +157,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Cut",
["Edit"],
Some("Cmd+X"),
"{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (selected ruler guides go too; the Artboard tool chosen: artboard.cut); refused while an object of a symbol instance's art is selected",
"{selectedArtboards?: [n…], art?: bool, lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (as edit.copy) (selected ruler guides go too; the Artboard tool chosen, or selectedArtboards given: artboard.cut); refused while an object of a symbol instance's art is selected",
has_selection_guides_or_artboard_tool,
cut
),
@@ -166,7 +166,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Copy",
["Edit"],
Some("Cmd+C"),
"{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy; with an object of a symbol instance's art selected, the instance is copied whole → {copied, guides}",
"{selectedArtboards?: [n…] (the artboards the Artboard tool had selected, which the journal records for a replay; it runs artboard.copy with any tool chosen), art?: bool (default: the Artboard tool's moveArt option; the journal records it beside selectedArtboards), lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy; with an object of a symbol instance's art selected, the instance is copied whole → {copied, guides}",
has_selection_guides_or_artboard_tool,
copy
),
@@ -946,15 +946,27 @@ fn copy_cut_targets(s: &Session) -> Result<(bool, Vec<NodeId>)> {
Ok((direct, roots))
}
/// The params edit.copy and edit.cut pass on to `artboard.copy` and `artboard.cut`.
fn artboard_clipboard_params(p: &Value) -> Value {
p.get("lockedAndHidden").map_or_else(|| json!({}), |v| json!({ "lockedAndHidden": v }))
/// Copy or Cut takes artboards: the Artboard tool is chosen, or the journal recorded the artboards
/// it took then.
fn takes_artboards(s: &Session, p: &Value) -> bool {
s.tool_id() == "artboard" || p.get("selectedArtboards").is_some()
}
/// Copy or Cut of artboards: `selectedArtboards`, `art` and `lockedAndHidden` of `p`, or their
/// defaults from the Artboard tool, which the journal entry records.
fn copy_artboards(s: &mut Session, p: &Value, cut: bool) -> Result<Value> {
let given: serde_json::Map<String, Value> =
["selectedArtboards", "art", "lockedAndHidden"].into_iter().filter_map(|key| p.get(key).map(|v| (key.to_string(), v.clone()))).collect();
let given = Value::Object(given);
super::panelcmds::boards_and_art_noted(s, &given, if cut { "edit.cut" } else { "edit.copy" })?;
super::panelcmds::artboard_copy(s, &given, cut)
}
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
switch_or(p, "art", false, "edit.copy")?;
// The Artboard tool copies its artboard (and the art on it).
if s.tool_id() == "artboard" {
return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), false);
if takes_artboards(s, p) {
return copy_artboards(s, p, false);
}
// With an object of a dynamic symbol instance's art selected, the instance is copied.
let (direct, targets) = copy_cut_targets(s)?;
@@ -994,8 +1006,9 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
}
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
if s.tool_id() == "artboard" {
return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), true);
switch_or(p, "art", false, "edit.cut")?;
if takes_artboards(s, p) {
return copy_artboards(s, p, true);
}
refuse_symbol_part(s)?;
// Ruler guides selected on their own: Copy takes them, Clear removes them.
+7 -4
View File
@@ -470,7 +470,7 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F
/// Path `l` as the canvas paints its fills and strokes: its visible geometry effects applied to its
/// path (a plain path then), and every geometry effect of its own removed. `None` when it has no
/// visible geometry effect.
fn reshaped(l: &Node) -> Option<Node> {
pub(crate) fn reshaped(l: &Node) -> Option<Node> {
use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, is_geometry};
if !has_geometry(&l.appearance.effects) {
return None;
@@ -490,7 +490,7 @@ fn reshaped(l: &Node) -> Option<Node> {
/// outline, box and fill: when the fill has visible geometry effects, `l` with the outline they
/// make of `path` as a plain path, that outline's box, and the fill with its gradient moved along
/// with the outline.
fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect, AppearanceItem)> {
pub(crate) fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect, AppearanceItem)> {
use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry};
if !has_geometry(item.effects()) {
return Some((l.clone(), path.clone(), bounds, item.clone()));
@@ -996,10 +996,13 @@ fn moves_only(a: Affine) -> bool {
(m0 - 1.0).abs() <= eps && m1.abs() <= eps && m2.abs() <= eps && (m3 - 1.0).abs() <= eps
}
/// Is `e` a visible Transform effect with Random on (which takes its seed from the object's id)?
pub(crate) fn seeded(e: &vectorcraft_doc::Effect) -> bool {
e.visible && e.id == "distort.transform" && e.params.get("random").and_then(Value::as_bool) == Some(true)
}
/// Does `n` have a visible Transform effect with Random on, its own or a fill's or stroke's?
fn seeded_here(n: &Node) -> bool {
let seeded =
|e: &vectorcraft_doc::Effect| e.visible && e.id == "distort.transform" && e.params.get("random").and_then(Value::as_bool) == Some(true);
n.appearance.effects.iter().any(seeded) || n.appearance.items.iter().any(|i| i.effects().iter().any(seeded))
}
+478 -30
View File
@@ -120,7 +120,7 @@ const ONE_FILE_WARNING: &str = "each data set saves over the same file, which ke
/// `path`; a Save step without a `path` when the document has a file of its own (saved, and not
/// converted from an older version or restored by Data Recovery) and the step gives no `format` or
/// the document's own; an Export for Screens step whose `folder`, given or from its
/// `exportPreset`, names a folder, whatever the overrides.
/// `exportPreset`, names a folder, when Override Export does not give each data set its own folder.
/// Other steps return their data instead of writing a file.
pub fn batch_overwrites_one_file(s: &Session, p: &Value) -> bool {
if p.get("source").and_then(Value::as_str) != Some("datasets") {
@@ -150,8 +150,8 @@ pub fn batch_overwrites_one_file(s: &Session, p: &Value) -> bool {
"document.export" | "document.exportSelection" => !export && path,
// The batch does not point these exports into `destinationFolder`.
"document.exportPdf" | "document.exportEps" | "document.exportDxf" | "document.exportForWeb" | "document.exportForOffice" => path,
// Override Export sets `path`, which Export for Screens does not read: its `folder` stays.
"document.exportForScreens" => super::screens::with_preset(s, &params).is_ok_and(|p| names(p.get("folder"))),
// Override Export gives Export for Screens a folder for each data set; otherwise its `folder` stays.
"document.exportForScreens" => !export && super::screens::with_preset(s, &params).is_ok_and(|p| names(p.get("folder"))),
_ => false,
}
})
@@ -196,14 +196,29 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run:
if files.is_empty() {
return Err(bad(cmd, "no readable files in the folder"));
}
// Each file's name without its extension, through [`source_stem`] (`a..svg` gives `a`, the
// folder Windows makes for `a.`, and `a\b.svg` keeps `a\b` where `\` is not a separator), with
// `-2`, `-3`… after a name an earlier file used (`a.svg` and `sub/a.svg`, or `a.svg` and `a.ai`) or after a name whose files or folder an earlier file
// wrote ([`StemPicker`]), as data set names are numbered. A file's stem is picked once it is
// open, so its artboard files count ([`dataset_output_paths`]). No output takes the name of a
// source file in the destination folder, other than that file's own output, or of a subfolder
// of the destination folder that holds source files.
let steps = filter_steps(run.steps, override_open, false);
let run = Run { steps: &steps, ..*run };
let in_dest: Vec<Option<(String, bool)>> = files.iter().map(|path| name_in_folder(path, run.dest_folder)).collect();
let mut picker =
StemPicker { stems: std::collections::HashSet::new(), written: in_dest.iter().flatten().map(|(name, _)| name.clone()).collect() };
let mut processed = Vec::new();
let mut failed = 0usize;
for path in &files {
let stem = std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("file");
for (path, place) in files.iter().zip(&in_dest) {
let ext = super::extension(path);
match process_one_file(s, &run, path, stem, &ext) {
let base = source_stem(std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or(""));
let own = place.as_ref().filter(|(_, own)| *own).map(|(name, _)| name.clone());
let pick = |doc: &vectorcraft_doc::Document| {
let outputs = dataset_output_extensions(&run, doc, &ext);
picker.pick(&base, |stem| output_names(run.dest_folder, stem, &outputs, doc), own.as_deref())
};
match process_one_file(s, &run, path, &ext, pick) {
Ok(row) => processed.push(row),
Err(e) => {
failed += 1;
@@ -260,6 +275,68 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result<Value> {
Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": false}))
}
/// The output stem for a source file's name without its extension: without trailing periods and
/// spaces and with `-` after a Windows device name ([`super::screens::name_without_device`]), and
/// "file" when nothing is left. Other characters stay, so `a\b` and `a:b` keep their names on Linux
/// and macOS.
#[cfg(not(target_arch = "wasm32"))]
fn source_stem(stem: &str) -> String {
let stem = super::screens::name_without_device(stem);
if stem.is_empty() { "file".to_string() } else { stem }
}
/// The names in lower case of the files and folders that a source with the stem `stem` writes
/// into the destination folder ([`dataset_output_paths`]), artboard files included.
#[cfg(not(target_arch = "wasm32"))]
fn output_names(folder: Option<&str>, stem: &str, outputs: &[DatasetOutput], doc: &vectorcraft_doc::Document) -> Vec<String> {
dataset_output_paths(folder, stem, outputs, doc)
.iter()
.filter_map(|path| path.file_name().and_then(|name| name.to_str()).map(str::to_lowercase))
.collect()
}
/// When the file at `path` is inside `folder`: the name in lower case of the file, when it is
/// directly inside, or of the subfolder of `folder` that holds it, and whether it is the file's own.
#[cfg(not(target_arch = "wasm32"))]
fn name_in_folder(path: &str, folder: Option<&str>) -> Option<(String, bool)> {
let folder = std::fs::canonicalize(folder?).ok()?;
let path = std::fs::canonicalize(path).ok()?;
let mut parts = path.strip_prefix(&folder).ok()?.components();
let first = parts.next()?.as_os_str().to_str()?.to_lowercase();
Some((first, parts.next().is_none()))
}
/// Picks the stem of each folder source in turn: its name, with `-2`, `-3`… after it when an
/// earlier source used it or when one of the files or the folder it writes has the name of a file
/// or folder an earlier source wrote (`a.svg` writes the file `a.svg`, so the Export for Screens
/// folder of `a.svg.svg` is `a.svg-2`) or a name in `written` from the start (the source files and
/// subfolders in the destination folder) other than its own file's. Names are compared without case.
#[cfg(not(target_arch = "wasm32"))]
struct StemPicker {
stems: std::collections::HashSet<String>,
written: std::collections::HashSet<String>,
}
#[cfg(not(target_arch = "wasm32"))]
impl StemPicker {
/// `names` gives the names in lower case of what a stem writes; `own` is the source's own name.
fn pick(&mut self, base: &str, names: impl Fn(&str) -> Vec<String>, own: Option<&str>) -> String {
let mut name = base.to_string();
let mut n = 1usize;
loop {
let lower = name.to_lowercase();
let outputs = names(&name);
if !self.stems.contains(&lower) && outputs.iter().all(|f| !self.written.contains(f) || own == Some(f.as_str())) {
self.stems.insert(lower);
self.written.extend(outputs);
return name;
}
n += 1;
name = format!("{base}-{n}");
}
}
}
/// Drop Open (and optionally Close) steps when the batch supplies the file itself.
#[cfg(not(target_arch = "wasm32"))]
fn filter_steps(cmds: &[Value], drop_open: bool, drop_close: bool) -> Vec<Value> {
@@ -279,12 +356,19 @@ fn filter_steps(cmds: &[Value], drop_open: bool, drop_close: bool) -> Vec<Value>
}
#[cfg(not(target_arch = "wasm32"))]
fn process_one_file(s: &mut Session, run: &Run<'_>, path: &str, stem: &str, ext: &str) -> Result<Value> {
fn process_one_file(
s: &mut Session,
run: &Run<'_>,
path: &str,
ext: &str,
stem_for: impl FnOnce(&vectorcraft_doc::Document) -> String,
) -> Result<Value> {
s.execute("document.open", &json!({"path": path}))?;
let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, stem, ext);
let stem = stem_for(&s.doc()?.doc);
let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, &stem, ext);
let batch = s.execute("command.batch", &json!({"label": run.label, "commands": rewritten}))?;
// Folder sources open a file per pass: close after Save and Close / Folder.
finish_destination(s, run, stem, ext, path, true)?;
finish_destination(s, run, &stem, ext, path, true)?;
Ok(json!({"path": path, "result": batch}))
}
@@ -349,10 +433,12 @@ fn dataset_stems(names: &[String], doc_stem: &str, naming: &str, run: &Run<'_>,
"fileNumber" => ordinal,
"dataSetName" if !safe_data_set.is_empty() => safe_data_set,
"dataSetName" => ordinal,
_ => format!("{doc_stem}_{safe}"),
// Without trailing periods and spaces, which Windows drops from a folder name, so the
// Export for Screens folders of `Set.` and `Set` are numbered apart.
_ => safe_file_name(&format!("{doc_stem}_{safe}")),
}
});
let outputs = dataset_output_extensions(run, doc);
let outputs = dataset_output_extensions(run, doc, "vectorcraft");
let mut taken = std::collections::HashSet::new();
let mut reserved = std::collections::HashSet::new();
let mut stems = Vec::with_capacity(bases.len());
@@ -384,30 +470,39 @@ struct DatasetOutput {
ext: String,
artboards: bool,
joiner: &'static str,
/// An Export for Screens or Export Assets step writes into the folder `{stem}` instead.
folder: bool,
}
/// Lists step output extensions and reserves artboard file names for saves with any artboard or
/// exports with multiple artboards. Per-artboard names count for vectorcraft, template, AI and AIT
/// saves and SVG, SVGZ, EPS, raster, DXF, EMF, WMF and AI exports.
/// saves and SVG, SVGZ, EPS, raster, DXF, EMF, WMF and AI exports. An Export for Screens or Export
/// Assets step reserves the folder `{stem}` ([`rewrite_paths`]). A step without a format and the
/// Folder destination write `default_ext` (`vectorcraft` when it is empty).
#[cfg(not(target_arch = "wasm32"))]
fn dataset_output_extensions(run: &Run<'_>, doc: &vectorcraft_doc::Document) -> Vec<DatasetOutput> {
fn dataset_output_extensions(run: &Run<'_>, doc: &vectorcraft_doc::Document, default_ext: &str) -> Vec<DatasetOutput> {
let default_ext = if default_ext.is_empty() { "vectorcraft" } else { default_ext };
let mut outputs = Vec::new();
if run.destination == "folder" {
outputs.push(DatasetOutput { ext: "vectorcraft".into(), artboards: false, joiner: "-" });
outputs.push(DatasetOutput { ext: default_ext.into(), artboards: false, joiner: "-", folder: false });
}
for step in run.steps {
let id = step.get("command").and_then(Value::as_str).unwrap_or("");
if run.override_export && matches!(id, "document.exportForScreens" | "assets.export") {
outputs.push(DatasetOutput { ext: String::new(), artboards: false, joiner: "-", folder: true });
continue;
}
let writes = (run.override_save && matches!(id, "document.save" | "file.save" | "file.saveAs"))
|| (run.override_export && matches!(id, "document.export" | "document.exportForScreens" | "document.exportSelection"));
|| (run.override_export && matches!(id, "document.export" | "document.exportSelection"));
if writes {
let format = step.get("params").and_then(|p| p.get("format")).and_then(Value::as_str).filter(|f| !f.is_empty());
let ext = match format {
Some(format) if super::format(format).is_some() => format_extension(format),
Some(_) => continue,
None => "vectorcraft".to_string(),
None => default_ext.to_string(),
};
if safe_extension(&ext) {
let format_id = format.and_then(super::format).map(|f| f.id).unwrap_or("vectorcraft");
let format_id = super::format(format.unwrap_or(default_ext)).map(|f| f.id).unwrap_or("vectorcraft");
let is_save = matches!(id, "document.save" | "file.save" | "file.saveAs");
let artboards = if is_save {
!doc.artboards.is_empty() && matches!(format_id, "vectorcraft" | "template" | "ai" | "ait")
@@ -434,7 +529,7 @@ fn dataset_output_extensions(run: &Run<'_>, doc: &vectorcraft_doc::Document) ->
| "ai"
)
};
outputs.push(DatasetOutput { ext, artboards, joiner: if format_id == "eps" { "_" } else { "-" } });
outputs.push(DatasetOutput { ext, artboards, joiner: if format_id == "eps" { "_" } else { "-" }, folder: false });
}
}
}
@@ -463,6 +558,10 @@ fn dataset_output_paths(folder: Option<&str>, stem: &str, outputs: &[DatasetOutp
}
let mut paths = Vec::new();
for output in outputs {
if output.folder {
paths.extend(output_folder(folder, stem));
continue;
}
if !safe_extension(&output.ext) || super::format(&output.ext).is_none() {
continue;
}
@@ -497,6 +596,18 @@ fn output_path(folder: &str, stem: &str, ext: &str) -> Option<std::path::PathBuf
(path.parent() == Some(Path::new(folder))).then_some(path)
}
/// Returns the folder `stem` directly inside the destination folder, where an Export for Screens or
/// Export Assets step writes, for a safe stem.
#[cfg(not(target_arch = "wasm32"))]
fn output_folder(folder: &str, stem: &str) -> Option<std::path::PathBuf> {
use std::path::Path;
if stem.is_empty() || matches!(stem, "." | "..") || stem.contains(std::path::is_separator) {
return None;
}
let path = Path::new(folder).join(stem);
(path.parent() == Some(Path::new(folder))).then_some(path)
}
/// Save when the extension is a Save format; otherwise export (PNG, JPEG…).
/// `as_copy`: `file.saveCopy` so the open document keeps its path (data-set batches).
#[cfg(not(target_arch = "wasm32"))]
@@ -513,7 +624,9 @@ fn write_result(s: &mut Session, path: &str, ext: &str, as_copy: bool) -> Result
}
/// Point Save / Export steps at `dest_folder/{stem}.{ext}` when overrides are on: `ext` is the
/// extension of the step's `format` ([`format_extension`]), else `default_ext`.
/// extension of the step's `format` ([`format_extension`]), else `default_ext`. Export for Screens
/// and Export Assets steps get `dest_folder/{stem}` as their `folder`, one folder for each source
/// file or data set, and name the files in it themselves.
#[cfg(not(target_arch = "wasm32"))]
fn rewrite_paths(
steps: &[Value],
@@ -532,21 +645,26 @@ fn rewrite_paths(
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
let mut c = c.clone();
let rewrite = (override_save && matches!(id, "document.save" | "file.save" | "file.saveAs"))
|| (override_export && matches!(id, "document.export" | "document.exportForScreens" | "document.exportSelection"));
|| (override_export && matches!(id, "document.export" | "document.exportForScreens" | "document.exportSelection" | "assets.export"));
if !rewrite {
return c;
}
let params = c.get("params").cloned().unwrap_or(json!({}));
let mut params = params.as_object().cloned().unwrap_or_default();
let ext = match params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()) {
Some(f) => format_extension(f),
None => default_ext.to_string(),
};
if params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()).is_some_and(|f| super::format(f).is_none()) {
return c;
if matches!(id, "document.exportForScreens" | "assets.export") {
let Some(dir) = output_folder(folder, stem) else { return c };
params.insert("folder".into(), json!(dir.to_string_lossy()));
} else {
let ext = match params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()) {
Some(f) => format_extension(f),
None => default_ext.to_string(),
};
if params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()).is_some_and(|f| super::format(f).is_none()) {
return c;
}
let Some(file) = output_path(folder, stem, &ext) else { return c };
params.insert("path".into(), json!(file.to_string_lossy()));
}
let Some(file) = output_path(folder, stem, &ext) else { return c };
params.insert("path".into(), json!(file.to_string_lossy()));
if let Some(o) = c.as_object_mut() {
o.insert("params".into(), Value::Object(params));
}
@@ -723,6 +841,15 @@ mod tests {
assert_eq!(file_names(&dest), ["a\\b.svg"], "{r}");
}
/// A source stem keeps `\`, `:` and the other characters Windows does not allow in a name, which
/// only reach the batch where the file system allows them, and loses trailing periods and spaces.
#[test]
fn source_stem_keeps_characters_the_file_system_allowed() {
for (stem, want) in [("a\\b", "a\\b"), ("a:b", "a:b"), ("a.", "a"), ("a. ", "a"), ("CON", "CON-"), ("...", "file"), ("", "file")] {
assert_eq!(source_stem(stem), want, "{stem:?}");
}
}
#[test]
fn output_path_accepts_only_platform_file_name_separators() {
let folder = tmp("output-path");
@@ -806,6 +933,319 @@ mod tests {
assert!(file_names(&dest).is_empty(), "{r}");
}
/// Override Export writes an Export for Screens step's files into a folder for each source file
/// inside the destination folder, under the names the export gives them, whether the step names
/// a folder of its own, takes one from an export preset or names none. With Override Export off,
/// the files go to the step's folder.
#[test]
fn batch_override_export_sends_export_for_screens_files_to_the_destination_folder() {
let src = tmp("screens-src");
write_svg(&src, "a.svg", "#f00");
write_svg(&src, "b.svg", "#0f0");
let own = tmp("screens-own");
let svg = json!([{"format": "svg"}]);
let preset = json!({"name": "Own Folder", "settings": {"formats": svg, "folder": own.to_string_lossy()}});
let steps = [json!({"folder": own.to_string_lossy(), "formats": svg}), json!({"exportPreset": "Own Folder"}), json!({"formats": svg})];
for (i, params) in steps.into_iter().enumerate() {
let dest = tmp(&format!("screens-out-{i}"));
let mut s = Session::new();
s.execute("document.exportPresets.save", &preset).unwrap();
let r = s
.execute(
"action.batch",
&json!({
"folder": src.to_string_lossy(),
"destinationFolder": dest.to_string_lossy(),
"commands": [{"command": "document.exportForScreens", "params": params}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{params}: {r}");
assert_eq!(file_names(&dest), ["a", "b"], "{params}: {r}");
for stem in ["a", "b"] {
assert_eq!(file_names(&dest.join(stem)), ["Artboard-1.svg"], "{params}: {r}");
}
assert!(file_names(&own).is_empty(), "{params}: {r}");
}
let dest = tmp("screens-out-off");
let mut s = Session::new();
let r = s
.execute(
"action.batch",
&json!({
"folder": src.to_string_lossy(),
"destinationFolder": dest.to_string_lossy(),
"overrideExport": false,
"commands": [{"command": "document.exportForScreens", "params": {"folder": own.to_string_lossy(), "formats": svg}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert_eq!(file_names(&own), ["Artboard-1.svg"], "{r}");
assert!(file_names(&dest).is_empty(), "{r}");
}
/// Two source files with the same name in different subfolders get Export for Screens folders of
/// their own: the second one's folder is numbered, as a repeated data set name is.
#[test]
fn batch_override_export_numbers_export_for_screens_folders_of_files_with_the_same_name() {
let src = tmp("screens-same-src");
std::fs::create_dir_all(src.join("sub")).unwrap();
write_svg(&src, "a.svg", "#f00");
write_svg(&src.join("sub"), "a.svg", "#0f0");
let dest = tmp("screens-same-out");
let mut s = Session::new();
let r = s
.execute(
"action.batch",
&json!({
"folder": src.to_string_lossy(),
"includeSubdirs": true,
"destinationFolder": dest.to_string_lossy(),
"commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}]
}),
)
.unwrap();
for folder in file_names(&dest) {
println!("{folder}/: {:?}", file_names(&dest.join(&folder)));
}
assert_eq!(r["failed"], 0, "{r}");
assert_eq!(file_names(&dest), ["a", "a-2"], "{r}");
for (folder, fill) in [("a", "#ff0000"), ("a-2", "#00ff00")] {
assert_eq!(file_names(&dest.join(folder)), ["Artboard-1.svg"], "{r}");
let svg = std::fs::read_to_string(dest.join(folder).join("Artboard-1.svg")).unwrap();
assert!(svg.contains(fill), "{folder}: {svg}");
}
}
/// A source file whose name without its extension ends in a period (`a..svg`) gets the folder
/// name without it, numbered after the folder of `a.svg`: Windows drops the period from a folder
/// name, so `a.` and `a` would be one folder there.
#[test]
fn batch_override_export_numbers_folders_that_differ_by_a_trailing_period() {
let src = tmp("screens-period-src");
write_svg(&src, "a.svg", "#f00");
write_svg(&src, "a..svg", "#0f0");
let dest = tmp("screens-period-out");
let mut s = Session::new();
let r = s
.execute(
"action.batch",
&json!({
"folder": src.to_string_lossy(),
"destinationFolder": dest.to_string_lossy(),
"commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert_eq!(file_names(&dest), ["a", "a-2"], "{r}");
for folder in ["a", "a-2"] {
assert_eq!(file_names(&dest.join(folder)), ["Artboard-1.svg"], "{r}");
}
}
/// A source file's Export for Screens folder does not take the name of a file the Folder
/// destination saved for an earlier source: `a.svg` is saved as `a.svg`, so the folder of
/// `a.svg.svg` is numbered.
#[test]
fn batch_override_export_numbers_folders_named_like_a_saved_file() {
let src = tmp("screens-file-src");
write_svg(&src, "a.svg", "#f00");
write_svg(&src, "a.svg.svg", "#0f0");
let dest = tmp("screens-file-out");
let mut s = Session::new();
let r = s
.execute(
"action.batch",
&json!({
"folder": src.to_string_lossy(),
"destination": "folder",
"destinationFolder": dest.to_string_lossy(),
"commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert_eq!(file_names(&dest), ["a", "a.svg", "a.svg-2", "a.svg-2.svg"], "{r}");
for folder in ["a", "a.svg-2"] {
assert_eq!(file_names(&dest.join(folder)), ["Artboard-1.svg"], "{r}");
}
}
/// With the destination folder the same as the source folder, a file's output does not replace
/// another source file before Batch opens it: `a..svg` would be saved as `a.svg`, so it is saved
/// as `a-2.svg`, and `a.svg` is saved over itself.
#[test]
fn batch_does_not_save_over_another_source_file_in_the_destination_folder() {
let src = tmp("in-place");
write_svg(&src, "a..svg", "#f00");
write_svg(&src, "a.svg", "#0f0");
let mut s = Session::new();
let r = s
.execute(
"action.batch",
&json!({
"folder": src.to_string_lossy(),
"destination": "folder",
"destinationFolder": src.to_string_lossy(),
"commands": [{"command": "object.unlockAll", "params": {}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert_eq!(file_names(&src), ["a-2.svg", "a..svg", "a.svg"], "{r}");
for (file, fill) in [("a-2.svg", "#ff0000"), ("a.svg", "#00ff00")] {
let svg = std::fs::read_to_string(src.join(file)).unwrap();
assert!(svg.contains(fill), "{file}: {svg}");
}
}
/// With the destination folder the same as the source folder and `includeSubdirs`, an Export for
/// Screens folder does not take the name of a subfolder that holds source files: the folder of
/// `a.svg` is `a-2`, so `a/Artboard-1.svg` is opened as it was.
#[test]
fn batch_does_not_export_into_a_source_subfolder() {
let src = tmp("in-place-sub");
std::fs::create_dir_all(src.join("a")).unwrap();
write_svg(&src, "a.svg", "#f00");
write_svg(&src.join("a"), "Artboard-1.svg", "#0f0");
let mut s = Session::new();
let r = s
.execute(
"action.batch",
&json!({
"folder": src.to_string_lossy(),
"includeSubdirs": true,
"destinationFolder": src.to_string_lossy(),
"commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{r}");
let source = std::fs::read_to_string(src.join("a").join("Artboard-1.svg")).unwrap();
assert!(source.contains("fill=\"#0f0\""), "{r}: {source}");
let export = std::fs::read_to_string(src.join("a-2").join("Artboard-1.svg")).unwrap();
assert!(export.contains("#ff0000"), "{r}: {export}");
}
/// With the destination folder the same as the source folder, a save's artboard files do not
/// replace another source file: `a.vectorcraft` would also write `a-Artboard-1.vectorcraft`, so
/// it is saved as `a-2`.
#[test]
fn batch_does_not_save_artboard_files_over_another_source_file() {
let src = tmp("in-place-artboards");
let mut s = Session::new();
for (name, width) in [("a.vectorcraft", 20), ("a-Artboard-1.vectorcraft", 30)] {
s.execute("file.new", &json!({"width": width, "height": 20, "artboards": 1})).unwrap();
s.execute("document.save", &json!({"path": src.join(name).to_string_lossy()})).unwrap();
}
let r = s
.execute(
"action.batch",
&json!({
"folder": src.to_string_lossy(),
"destinationFolder": src.to_string_lossy(),
"commands": [{"command": "document.save", "params": {"separateArtboards": true}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert_eq!(
file_names(&src),
["a-2-Artboard-1.vectorcraft", "a-2.vectorcraft", "a-Artboard-1-Artboard-1.vectorcraft", "a-Artboard-1.vectorcraft", "a.vectorcraft"],
"{r}"
);
s.execute("document.open", &json!({"path": src.join("a-Artboard-1.vectorcraft").to_string_lossy()})).unwrap();
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.width(), 30.0, "{r}");
}
/// Data sets whose names differ only by a trailing period (`a` and `a.`) get Export Assets
/// folders of their own: the second one's folder is numbered.
#[test]
fn batch_override_export_numbers_data_set_folders_that_differ_by_a_trailing_period() {
let dest = tmp("assets-period-out");
let mut s = with_data_sets(&["a", "a."]);
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
s.execute("assets.add", &json!({"ids": [id], "name": "Logo"})).unwrap();
let r = s
.execute(
"action.batch",
&json!({
"source": "datasets",
"destinationFolder": dest.to_string_lossy(),
"commands": [{"command": "assets.export", "params": {"formats": [{"format": "svg"}]}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert_eq!(file_names(&dest), ["Untitled_a", "Untitled_a-2"], "{r}");
for folder in ["Untitled_a", "Untitled_a-2"] {
assert_eq!(file_names(&dest.join(folder)), ["Logo.svg"], "{r}");
}
}
/// Two source files with the same name in different subfolders are saved under names of their
/// own, by Override Save and by the Folder destination: the second one's name is numbered.
#[test]
fn batch_numbers_saved_files_of_source_files_with_the_same_name() {
let src = tmp("save-same-src");
std::fs::create_dir_all(src.join("sub")).unwrap();
write_svg(&src, "a.svg", "#f00");
write_svg(&src.join("sub"), "a.svg", "#0f0");
let save = json!([{"command": "file.saveAs", "params": {"format": "svg"}}]);
let unlock = json!([{"command": "object.unlockAll", "params": {}}]);
for (tag, destination, commands) in [("save", "none", save), ("folder", "folder", unlock)] {
let dest = tmp(&format!("save-same-{tag}"));
let mut s = Session::new();
let r = s
.execute(
"action.batch",
&json!({
"folder": src.to_string_lossy(),
"includeSubdirs": true,
"destination": destination,
"destinationFolder": dest.to_string_lossy(),
"commands": commands
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{tag}: {r}");
assert_eq!(file_names(&dest), ["a-2.svg", "a.svg"], "{tag}: {r}");
for (file, fill) in [("a.svg", "#ff0000"), ("a-2.svg", "#00ff00")] {
let svg = std::fs::read_to_string(dest.join(file)).unwrap();
assert!(svg.contains(fill), "{tag}: {file}: {svg}");
}
}
}
/// Override Export writes an Export Assets step's files into a folder for each data set inside
/// the destination folder, under the assets' names.
#[test]
fn batch_override_export_sends_asset_export_files_to_the_destination_folder() {
let own = tmp("assets-own");
let dest = tmp("assets-out");
let mut s = with_data_sets(&["a", "b"]);
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
s.execute("assets.add", &json!({"ids": [id], "name": "Logo"})).unwrap();
let r = s
.execute(
"action.batch",
&json!({
"source": "datasets",
"destinationFolder": dest.to_string_lossy(),
"commands": [{"command": "assets.export", "params": {"folder": own.to_string_lossy(), "formats": [{"format": "svg"}]}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert_eq!(file_names(&dest), ["Untitled_a", "Untitled_b"], "{r}");
for stem in ["Untitled_a", "Untitled_b"] {
assert_eq!(file_names(&dest.join(stem)), ["Logo.svg"], "{r}");
}
assert!(file_names(&own).is_empty(), "{r}");
}
/// Override Save names each file with the extension of the format the step names, also over
/// data sets.
#[test]
@@ -1258,7 +1698,7 @@ mod tests {
assert!(!dataset_output_exists(
Some(&folder),
"../outside",
&[DatasetOutput { ext: "svg".into(), artboards: false, joiner: "-" }],
&[DatasetOutput { ext: "svg".into(), artboards: false, joiner: "-", folder: false }],
&vectorcraft_doc::Document::new(100.0, 80.0),
));
}
@@ -1525,7 +1965,15 @@ mod tests {
assert!(!batch_overwrites_one_file(&saved, &save(Some("svg"))), "another format without a path: the data comes back");
assert!(!with(json!({"commands": [{"command": "file.saveCopy", "params": {"folder": "/x"}}]})), "a folder alone: the data comes back");
let screens = json!([{"command": "document.exportForScreens", "params": {"folder": "/x"}}]);
assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "commands": screens})), "Override Export leaves its folder");
assert!(
!with(json!({"destination": "folder", "destinationFolder": "/out", "commands": screens})),
"Override Export gives each data set its own folder"
);
assert!(
with(json!({"destination": "folder", "destinationFolder": "/out", "overrideExport": false, "commands": screens})),
"without Override Export the step keeps its folder"
);
assert!(with(json!({"commands": screens})), "without a destination folder the step keeps its folder");
assert!(!with(json!({"commands": [{"command": "document.exportForScreens", "params": {}}]})));
let preset = json!([{"command": "document.exportForScreens", "params": {"exportPreset": "Logo Package"}}]);
assert!(!with(json!({"commands": preset})), "a preset without a folder: the data comes back");
+4 -4
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Serialize Document",
[],
None,
"{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
"{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored",
has_doc,
export::serialize
),
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Document",
[],
None,
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path",
has_doc,
export::export
),
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Selection…",
["File"],
None,
"{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export",
"{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored",
has_selection,
export::export_selection
),
@@ -117,7 +117,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Batch…",
[],
None,
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; for source datasets, save the open document over its own file after each data set, and refuse a document that was never saved, was converted from an older version or was restored by Data Recovery, unless a recorded Save or Save As step writes a file first; a Save or Save As step that overrideSave points into destinationFolder makes each data set's file the document's own) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-`; a name already used in the batch or whose output already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped, warning?} (warning: for source datasets, when Save and Close without such a step, or a Save, Save a Copy, Save as Template or export step (document.export, exportSelection, exportForScreens, exportPdf, exportEps, exportDxf, exportForWeb, exportForOffice) that writes a file and is not pointed into destinationFolder, saves every data set over the same file)",
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; for source datasets, save the open document over its own file after each data set, and refuse a document that was never saved, was converted from an older version or was restored by Data Recovery, unless a recorded Save or Save As step writes a file first; a Save or Save As step that overrideSave points into destinationFolder makes each data set's file the document's own) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension and without trailing periods and spaces, with `-2`, `-3`… added to a name an earlier file used or whose file or folder has the name of one an earlier file wrote or of another source file or a folder of source files in destinationFolder), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-` and trailing periods and spaces are dropped; a name already used in the batch or whose output file or folder already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped, warning?} (warning: for source datasets, when Save and Close without such a step, or a Save, Save a Copy, Save as Template or export step (document.export, exportSelection, exportForScreens, exportPdf, exportEps, exportDxf, exportForWeb, exportForOffice) that writes a file and is not pointed into destinationFolder, saves every data set over the same file)",
always,
action_batch::action_batch
),
+2 -2
View File
@@ -21,14 +21,14 @@ pub fn specs() -> Vec<CommandSpec> {
"Export DXF",
[],
None,
"{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; rasterFormat png; the options given override it), version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}",
"{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; rasterFormat png; the options given override it), version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}. A key DXF doesn't take is refused",
has_doc,
export_dxf
)]
}
/// The DXF options `document.formats` lists.
pub(super) const OPTIONS: &[FormatOption] = &[
pub(crate) const OPTIONS: &[FormatOption] = &[
super::ARTBOARD,
super::ARTBOARDS,
super::RANGE,
+2 -2
View File
@@ -32,14 +32,14 @@ pub fn specs() -> Vec<CommandSpec> {
"Export EPS",
[],
None,
"{path?, level?: 2|3 (default; PostScript language level: 3 writes smooth gradients and masks transparent image pixels out, 2 steps gradients and writes those pixels white), previewFormat?: none|tiffBw|tiffColor (default: a TIFF preview other apps show), transparentPreview?: true (else on white), overprints?: preserve (default)|discard, flattenerPreset?: \"medium\" (default; high, low or a saved preset: flattener.presets.list), flattener?: {the options of object.flattenTransparency over the preset}, embedFonts?: true (type is always written as outlines, so no fonts are needed), includeLinkedFiles?: false (the embedded document keeps linked images whole, not just their previews), thumbnails?: true (a PNG thumbnail in the file), cmykPostScript?: true (RGB documents written in CMYK), compatibleGradients?: false (stepped gradients at level 3 too), selectedOnly?: false, useArtboards?: true (one file per chosen artboard, artboards?/range?, {stem}_{artboard}.eps, the bounding box the artboard; with two or more, also the central file {stem}.eps first: the whole document, the bounding box the visible art; none when no art shows) | false (one file, the bounding box the visible art, y up from the first artboard's bottom-left corner, even when artboards are named; the default when none are named), format?: \"eps\", artboard?: i | artboards?: [i…] | range?: \"1-3, 5\" (the artboards written; naming them without useArtboards works as useArtboards: true)} → {path, bytes, warnings, files?}; no path → {dataBase64, …}. Transparency is flattened with the preset (EPS has none); the native document rides along in comments, for reopening the file as it was. Same as document.export {format: eps}",
"{path?, level?: 2|3 (default; PostScript language level: 3 writes smooth gradients and masks transparent image pixels out, 2 steps gradients and writes those pixels white), previewFormat?: none|tiffBw|tiffColor (default: a TIFF preview other apps show), transparentPreview?: true (else on white), overprints?: preserve (default)|discard, flattenerPreset?: \"medium\" (default; high, low or a saved preset: flattener.presets.list), flattener?: {the options of object.flattenTransparency over the preset}, embedFonts?: true (type is always written as outlines, so no fonts are needed), includeLinkedFiles?: false (the embedded document keeps linked images whole, not just their previews), thumbnails?: true (a PNG thumbnail in the file), cmykPostScript?: true (RGB documents written in CMYK), compatibleGradients?: false (stepped gradients at level 3 too), selectedOnly?: false, useArtboards?: true (one file per chosen artboard, artboards?/range?, {stem}_{artboard}.eps, the bounding box the artboard; with two or more, also the central file {stem}.eps first: the whole document, the bounding box the visible art; none when no art shows) | false (one file, the bounding box the visible art, y up from the first artboard's bottom-left corner, even when artboards are named; the default when none are named), format?: \"eps\", artboard?: i | artboards?: [i…] | range?: \"1-3, 5\" (the artboards written; naming them without useArtboards works as useArtboards: true)} → {path, bytes, warnings, files?}; no path → {dataBase64, …}. Transparency is flattened with the preset (EPS has none); the native document rides along in comments, for reopening the file as it was. Same as document.export {format: eps}. A key EPS doesn't take, and an unknown key inside flattener, are refused",
has_doc,
export_eps
)]
}
/// The EPS options `document.formats` lists.
pub(super) const OPTIONS: &[FormatOption] = &[
pub(crate) const OPTIONS: &[FormatOption] = &[
super::ARTBOARD,
super::ARTBOARDS,
super::RANGE,
File diff suppressed because one or more lines are too long
+7 -1
View File
@@ -174,7 +174,13 @@ pub(super) fn file_part(s: &str) -> String {
/// every platform. Spaces between the device name and the period are dropped (`CON .png` becomes
/// `CON-.png`).
pub(crate) fn safe_file_name(s: &str) -> String {
let name = file_part(s);
name_without_device(&file_part(s))
}
/// [`safe_file_name`] without [`file_part`]: drops trailing periods and spaces and puts `-` after a
/// Windows device name, and keeps every other character, so a source file name with `\` or `:` keeps
/// it where the file system allows it.
pub(super) fn name_without_device(name: &str) -> String {
let name = name.trim_end_matches(['.', ' ']);
let (base, rest) = name.split_at(name.find('.').unwrap_or(name.len()));
// Windows ignores spaces before the extension, so `CON .png` is the CON device too.
@@ -445,6 +445,39 @@ fn a_graphic_style_library_in_a_ai_file_without_layers_loads_its_styles() {
assert_eq!(styles, [("Red", 1), ("Green", 2), ("Blue", 3)], "{r}");
}
/// A `.ai` symbol library (symbols and their list, no layers) opens with its symbols, and Other
/// Library… in the Symbols panel lists them in the list's order.
#[test]
fn a_symbol_library_in_a_ai_file_without_layers_loads_its_symbols() {
let symbol = |name: &str, x: f64| {
format!(
"%AI14_BeginSymbol\n({name})\n0 A\n0 Xw\n0 Ae\nu\n0 1 1 0 1 0 0 Xa\n{x} 0 m\n{x} 4 L\n{} 4 L\n{} 0 L\nf\nU\n\
%_/ArtDictionary :\n%_5 /Int (AI13PatternRegistrationType) ,\n%_;\n%_\n9 () XW\n%AI10_EndSymbol\n",
x + 4.0,
x + 4.0
)
};
let symbols = format!(
"%AI5_Begin_NonPrinting\n{}{}{}%AI5_End_NonPrinting--\n%AI24_BeginSymbolList\n(Star)\n(Leaf)\n(Arrow)\n%AI24_EndSymbolList\n",
symbol("Arrow", 0.0),
symbol("Leaf", 10.0),
symbol("Star", 20.0)
);
let data = ai::editing_data(200.0, 100.0, &symbols);
let file = ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 10 50 30 40 re f");
let mut s = Session::new();
let p = json!({"dataBase64": vectorcraft_format::base64_encode(&file), "name": "shapes.ai"});
let r = s.execute("symbol.loadLibrary", &p).unwrap();
assert_eq!((&r["name"], &r["count"]), (&json!("shapes"), &json!(3)), "{r}");
let lib = s.execute("symbol.library", &json!({"library": r["library"]})).unwrap();
let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|g| g["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Star", "Leaf", "Arrow"], "{lib}");
let r = open(&mut s, "shapes.ai", &file, json!({})).unwrap();
let doc = &s.doc().unwrap().doc;
let names: Vec<&str> = doc.symbols.iter().map(|g| g.name.as_str()).collect();
assert_eq!(names, ["Star", "Leaf", "Arrow"], "{r}");
}
/// A 1-bit RGB image opens from the editing data in the colors its PDF part shows.
#[test]
fn a_one_bit_rgb_image_opens_from_the_editing_data_in_its_colors() {
+4 -1
View File
@@ -42,7 +42,10 @@ use data::{fmt_value, parse_csv, to_csv};
use generate::generate;
#[cfg(test)]
use series::default_series_color;
pub(crate) use series::{capture_label_styles, capture_series_opacity, capture_series_paints, capture_series_top_paints, paint_targets, shown_paint};
pub(crate) use series::{
capture_label_styles, capture_series_object_opacity, capture_series_opacity, capture_series_paints, capture_series_top_paints, paint_targets,
series_object_opacity_differs, shown_paint,
};
#[cfg(test)]
mod tests;
+98 -20
View File
@@ -1,3 +1,5 @@
use vectorcraft_color::BlendMode;
use super::axis::radar_axis;
use super::labels::{Role, filled_lines_on, label_style};
use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad};
@@ -26,26 +28,43 @@ pub(super) fn series_marks(g: &GraphSpec, i: usize, series_fill: bool, stroke: P
(fill, stroke, width)
}
/// The paths directly inside series `i`'s group (`items`) with the series' kept fill and stroke opacity on their
/// topmost fill and stroke. Other art, such as a graph design, keeps its own opacity. A value read from a file is
/// held to 0..1, and one that is not a number is left out.
/// The paths directly inside series `i`'s group (`items`) with the series' kept fill and stroke opacity and blend mode on
/// their topmost fill and stroke, and its kept object opacity and blend mode. Other art, such as a graph design, keeps its
/// own. An opacity read from a file is held to 0..1, and one that is not a number is left out.
pub(super) fn with_series_opacity(g: &GraphSpec, i: usize, mut items: Vec<Arc<Node>>) -> Vec<Arc<Node>> {
let Some(slot) = g.series_paints.get(i) else { return items };
let kept = |o: Option<f32>| o.filter(|o| o.is_finite()).map(|o| o.clamp(0.0, 1.0));
let (fill, stroke) = (kept(slot.fill_opacity), kept(slot.stroke_opacity));
if fill.is_none() && stroke.is_none() {
let (fill, stroke, object) = (kept(slot.fill_opacity), kept(slot.stroke_opacity), kept(slot.opacity));
if fill.is_none() && stroke.is_none() && object.is_none() && slot.fill_blend.is_none() && slot.stroke_blend.is_none() && slot.blend.is_none() {
return items;
}
for item in &mut items {
if !matches!(item.kind, NodeKind::Path { .. }) {
continue;
}
let ap = &mut Arc::make_mut(item).appearance;
if let (Some(o), Some(f)) = (fill, ap.fill_mut()) {
f.opacity = o;
let n = Arc::make_mut(item);
if let Some(o) = object {
n.opacity = o;
}
if let (Some(o), Some(st)) = (stroke, ap.stroke_mut()) {
st.opacity = o;
if let Some(b) = slot.blend {
n.blend = b;
}
let ap = &mut n.appearance;
if let Some(f) = ap.fill_mut() {
if let Some(o) = fill {
f.opacity = o;
}
if let Some(b) = slot.fill_blend {
f.blend = b;
}
}
if let Some(st) = ap.stroke_mut() {
if let Some(o) = stroke {
st.opacity = o;
}
if let Some(b) = slot.stroke_blend {
st.blend = b;
}
}
}
items
@@ -216,10 +235,10 @@ fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, P
changed
}
/// Copy the opacity of the topmost visible fill (`true`) or stroke (`false`) of generated series art just edited (`edited`:
/// each object with the kind of row) into that series' fill or stroke opacity; art left without a row of that kind
/// keeps none. Called inside the same document edit, so both undo together. Returns whether a series' kept opacity
/// changed.
/// Copy the opacity and blend mode of the topmost visible fill (`true`) or stroke (`false`) of generated series art just
/// edited (`edited`: each object with the kind of row) into that series' fill or stroke opacity and blend mode; art left
/// without a row of that kind keeps neither. Called inside the same document edit, so both undo together. Returns
/// whether a series' kept opacity or blend mode changed.
pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool)]) -> bool {
if edited.is_empty() {
return false;
@@ -236,14 +255,15 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool
let node = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. }))?;
let row = node.appearance.items.iter().rev().find(|it| it.is_fill() == *fill && it.visible());
let opacity = row.map_or(1.0, vectorcraft_doc::AppearanceItem::opacity);
Some((r.graph, r.index, *fill, (opacity < 1.0).then_some(opacity)))
let blend = row.map_or(BlendMode::Normal, vectorcraft_doc::AppearanceItem::blend);
Some((r.graph, r.index, *fill, (opacity < 1.0).then_some(opacity), (blend != BlendMode::Normal).then_some(blend)))
})
.collect();
let mut changed = false;
for (graph, index, fill, kept) in captures {
for (graph, index, fill, kept, blend) in captures {
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
// A fully opaque row on a series the graph keeps nothing for leaves the graph as it is.
if kept.is_none() && spec.series_paints.len() <= index {
// A fully opaque Normal row on a series the graph keeps nothing for leaves the graph as it is.
if kept.is_none() && blend.is_none() && spec.series_paints.len() <= index {
continue;
}
let was = spec.series_paints.clone();
@@ -253,15 +273,73 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool
}
let Some(slot) = spec.series_paints.get_mut(index) else { continue };
if fill {
slot.fill_opacity = kept;
(slot.fill_opacity, slot.fill_blend) = (kept, blend);
} else {
slot.stroke_opacity = kept;
(slot.stroke_opacity, slot.stroke_blend) = (kept, blend);
}
changed |= spec.series_paints != was;
}
changed
}
/// Copy the object opacity and blend mode of generated series art just edited (`edited`) into that series' object opacity
/// and blend mode. Called inside the same document edit, so both undo together. Returns whether a series' kept object
/// opacity or blend mode changed.
pub(crate) fn capture_series_object_opacity(doc: &mut Document, edited: &[NodeId]) -> bool {
let mut changed = false;
for (graph, index, opacity, blend) in object_opacity_captures(doc, edited) {
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
// A fully opaque Normal object on a series the graph keeps nothing for leaves the graph as it is.
if opacity.is_none() && blend.is_none() && spec.series_paints.len() <= index {
continue;
}
let was = spec.series_paints.clone();
// `index` is below the graph's series count (`series_members`).
if spec.series_paints.len() <= index {
spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default());
}
let Some(slot) = spec.series_paints.get_mut(index) else { continue };
(slot.opacity, slot.blend) = (opacity, blend);
changed |= spec.series_paints != was;
}
changed
}
/// Would [`capture_series_object_opacity`] change what the graph stores for `edited`? Reads the document without copying
/// it.
pub(crate) fn series_object_opacity_differs(doc: &Document, edited: &[NodeId]) -> bool {
let mut after: HashMap<(NodeId, usize), (Option<f32>, Option<BlendMode>)> = HashMap::new();
let stored = |graph: NodeId, index: usize| {
let spec = doc.node(graph).and_then(|n| n.extras().graph.as_deref());
spec.and_then(|g| g.series_paints.get(index)).map_or((None, None), |slot| (slot.opacity, slot.blend))
};
for (graph, index, opacity, blend) in object_opacity_captures(doc, edited) {
after.insert((graph, index), (opacity, blend));
}
after.into_iter().any(|((graph, index), pair)| stored(graph, index) != pair)
}
/// The series and object opacity and blend mode (None for 100% and Normal) of each of `edited` that is a path directly in a
/// series group, in order.
fn object_opacity_captures(doc: &Document, edited: &[NodeId]) -> Vec<(NodeId, usize, Option<f32>, Option<BlendMode>)> {
if edited.is_empty() {
return Vec::new();
}
let members = series_members(doc);
if members.is_empty() {
return Vec::new();
}
edited
.iter()
.filter_map(|id| {
// Only the paths the graph draws again, not the copy of a graph design.
let r = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group))?;
let node = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. }))?;
Some((r.graph, r.index, (node.opacity < 1.0).then_some(node.opacity), (node.blend != BlendMode::Normal).then_some(node.blend)))
})
.collect()
}
/// Graph Data › Transpose row/column: categories become series and series categories.
pub(super) fn transpose(g: &mut GraphSpec) {
let cells = g.cells();
+234 -13
View File
@@ -1,5 +1,5 @@
use serde_json::json;
use vectorcraft_color::Paint;
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::{CharStyle, ColumnDesign, ColumnType, Fractions, GraphKind, GraphSpec, NodeKind};
use crate::{NodeId, Session};
@@ -347,14 +347,14 @@ fn clearing_a_series_marks_appearance_forgets_its_kept_opacity() {
}
/// Removing the fill whose opacity the graph keeps takes the kept opacity away; an edit that leaves a mark opaque
/// (a blend mode alone) keeps nothing for its series.
/// and Normal (an opacity of 100% alone) keeps nothing for its series.
#[test]
fn removing_a_series_marks_fill_forgets_its_kept_opacity() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "blend": "multiply"})).unwrap();
s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 100})).unwrap();
assert!(spec(&s, id).series_paints.is_empty());
s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 40})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.4));
@@ -503,6 +503,198 @@ fn a_stroke_opacity_on_a_series_line_survives_regeneration() {
assert!(!after.is_empty() && after.iter().all(|m| m.appearance.stroke().unwrap().opacity == 0.6));
}
/// Each mark of series `index`: its object opacity and blend mode, and the blend modes of its top fill and stroke.
fn transparency_of(s: &Session, id: NodeId, index: u32) -> Vec<(f32, BlendMode, BlendMode, BlendMode)> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let marks = series(&n, index).children().unwrap().to_vec();
marks.iter().map(|m| (m.opacity, m.blend, m.appearance.fill().unwrap().blend, m.appearance.stroke().unwrap().blend)).collect()
}
/// An object opacity and blend mode set on one series mark with no fill or stroke row picked are kept by the graph:
/// every mark of that series has them after the graph is drawn again, through save and open, the other series stay
/// opaque and Normal, 100% and Normal forget them, and undo takes them back.
#[test]
fn an_object_opacity_and_blend_mode_on_a_series_mark_survive_regeneration() {
let (mut s, id, _, marks) = column_marks();
s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.opacity, kept.blend), (Some(0.4), Some(BlendMode::Multiply)));
let restored: GraphSpec = serde_json::from_value(serde_json::to_value(spec(&s, id)).unwrap()).unwrap();
assert_eq!(restored, spec(&s, id));
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
let normal = BlendMode::Normal;
assert!(transparency_of(&s, id, 0).iter().all(|t| *t == (0.4, BlendMode::Multiply, normal, normal)), "{:?}", transparency_of(&s, id, 0));
assert!(transparency_of(&s, id, 1).iter().all(|t| *t == (1.0, normal, normal, normal)), "{:?}", transparency_of(&s, id, 1));
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
s.execute("transparency.set", &json!({"ids": [bar.0], "opacity": 100, "blend": "normal"})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.opacity, kept.blend), (None, None));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(spec(&s, id).series_paints.is_empty(), "undo takes back the edit and what the graph stored");
}
/// `object.setProps` with the opacity a mark already shows replaces the opacity another mark of the series left on the
/// graph, in one undo step, and an opacity on a graph design's copy in a series is not kept.
#[test]
fn an_object_opacity_a_series_mark_already_shows_replaces_the_kept_opacity() {
let (mut s, id, _, marks) = column_marks();
s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 50})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.5));
s.execute("object.setProps", &json!({"id": marks[1].0, "opacity": 100})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].opacity, None);
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
assert!(transparency_of(&s, id, 0).iter().all(|t| t.0 == 1.0), "{:?}", transparency_of(&s, id, 0));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.5));
// Setting it again where the graph already stores it leaves the document and its history as they are.
let undo = s.doc().unwrap().history.undo.len();
s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 50})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
save_design(&mut s, "Square");
s.execute("graph.column", &json!({"id": id.0, "design": "Square"})).unwrap();
let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone();
assert!(matches!(copy.kind, NodeKind::Group { .. }));
s.execute("object.setProps", &json!({"id": copy.id.0, "opacity": 50})).unwrap();
assert!(spec(&s, id).series_paints.iter().all(|p| p.opacity.is_none()));
}
/// A blend mode set on the top fill or stroke row of one series mark is kept by the graph and drawn on every mark of
/// the series again; Normal forgets it.
#[test]
fn a_row_blend_mode_on_a_series_mark_survives_regeneration() {
let (mut s, id, _, marks) = column_marks();
let (fill, stroke) = (top_row(&s, marks[0], true), top_row(&s, marks[0], false));
s.execute("transparency.set", &json!({"ids": [marks[0].0], "item": fill, "blend": "screen"})).unwrap();
s.execute("transparency.set", &json!({"ids": [marks[1].0], "item": stroke, "blend": "darken"})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill_blend, kept.stroke_blend), (Some(BlendMode::Screen), Some(BlendMode::Darken)));
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
let normal = BlendMode::Normal;
assert!(transparency_of(&s, id, 0).iter().all(|t| *t == (1.0, normal, BlendMode::Screen, BlendMode::Darken)), "{:?}", transparency_of(&s, id, 0));
assert!(transparency_of(&s, id, 1).iter().all(|t| *t == (1.0, normal, normal, normal)), "{:?}", transparency_of(&s, id, 1));
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
s.execute("transparency.set", &json!({"ids": [bar.0], "item": top_row(&s, bar, true), "blend": "normal"})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill_blend, None);
}
/// The graph stores an object's or a row's opacity and blend mode as one pair from the mark just edited: setting only
/// one half on another mark replaces both, either way round, and the redraw and undo follow the stored pair.
#[test]
fn setting_half_of_a_series_marks_transparency_stores_that_marks_whole_pair() {
let normal = BlendMode::Normal;
let (mut s, id, _, marks) = column_marks();
s.execute("transparency.set", &json!({"ids": [marks[0].0], "blend": "multiply"})).unwrap();
s.execute("object.setProps", &json!({"id": marks[1].0, "opacity": 50})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.opacity, kept.blend), (Some(0.5), None));
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
assert!(transparency_of(&s, id, 0).iter().all(|t| (t.0, t.1) == (0.5, normal)), "{:?}", transparency_of(&s, id, 0));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.opacity, kept.blend), (None, Some(BlendMode::Multiply)));
let (mut s, id, _, marks) = column_marks();
s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 30})).unwrap();
s.execute("transparency.set", &json!({"ids": [marks[1].0], "blend": "screen"})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.opacity, kept.blend), (None, Some(BlendMode::Screen)));
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
assert!(transparency_of(&s, id, 0).iter().all(|t| (t.0, t.1) == (1.0, BlendMode::Screen)), "{:?}", transparency_of(&s, id, 0));
// A fill row: a blend alone on one mark replaces the opacity another mark's row left, and the other way round.
let (mut s, id, _, marks) = column_marks();
let fill = top_row(&s, marks[0], true);
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 40})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": fill, "blend": "darken"})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill_opacity, kept.fill_blend), (None, Some(BlendMode::Darken)));
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 60})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill_opacity, kept.fill_blend), (Some(0.6), None));
s.execute("edit.undo", &json!({})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill_opacity, kept.fill_blend), (None, Some(BlendMode::Darken)));
}
/// Clear Appearance on a series mark sets its object opacity back to 100% and Normal, and the graph keeps neither.
#[test]
fn clearing_a_series_marks_appearance_forgets_its_kept_object_opacity() {
let (mut s, id, _, marks) = column_marks();
s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap();
s.execute("appearance.clear", &json!({"ids": [marks[1].0]})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.opacity, kept.blend), (None, None));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.4));
}
/// An appearance moved or copied onto a mark of another series takes the source's object opacity and blend mode, and
/// the graph keeps them for the target's series; a move leaves the source's series opaque and Normal.
#[test]
fn moving_an_appearance_onto_a_series_mark_keeps_its_object_opacity() {
for copy in [false, true] {
let (mut s, id, _, marks) = column_marks();
let to = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap();
s.execute("appearance.transfer", &json!({"source": marks[0].0, "target": to.0, "copy": copy})).unwrap();
let paints = spec(&s, id).series_paints;
assert_eq!((paints[1].opacity, paints[1].blend), (Some(0.4), Some(BlendMode::Multiply)), "copy {copy}");
let source = if copy { (Some(0.4), Some(BlendMode::Multiply)) } else { (None, None) };
assert_eq!((paints[0].opacity, paints[0].blend), source, "copy {copy}");
}
}
/// An Eyedropper pick of the object's transparency onto a series mark makes the graph keep the picked opacity and blend
/// mode for that series, even an opaque Normal pick onto a mark that already shows it.
#[test]
fn an_eyedropper_pick_of_object_transparency_onto_a_series_mark_is_kept() {
let (mut s, id, _, marks) = column_marks();
let to = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id;
s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap();
s.execute("appearance.copyFrom", &json!({"source": marks[0].0, "ids": [to.0]})).unwrap();
let kept = spec(&s, id).series_paints[1].clone();
assert_eq!((kept.opacity, kept.blend), (Some(0.4), Some(BlendMode::Multiply)));
// An opaque Normal source picked onto another opaque mark of series 0 replaces what series 0 stores.
let opaque = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[1].id;
s.execute("appearance.copyFrom", &json!({"source": opaque.0, "ids": [marks[1].0]})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.opacity, kept.blend), (None, None));
}
/// A graphic style made from an object at 50% Multiply, applied to a series mark, makes the graph keep the style's object
/// opacity and blend mode; redefining the style updates them for a mark still linked to it.
#[test]
fn a_graphic_styles_object_opacity_on_a_series_mark_is_kept() {
let (mut s, id, _, marks) = column_marks();
let (rect, _) = blue_style(&mut s, "Blue", true);
s.execute("object.setProps", &json!({"id": rect.0, "opacity": 50, "blend": "multiply"})).unwrap();
s.execute("graphicStyle.new", &json!({"id": rect.0, "name": "Faded"})).unwrap();
s.execute("graphicStyle.apply", &json!({"name": "Faded", "ids": [marks[0].0]})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.opacity, kept.blend), (Some(0.5), Some(BlendMode::Multiply)));
s.execute("object.setProps", &json!({"id": rect.0, "opacity": 100, "blend": "normal"})).unwrap();
s.execute("graphicStyle.redefine", &json!({"name": "Faded", "id": rect.0})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.opacity, kept.blend), (None, None));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.5), "one undo takes back the redefinition and what the graph stored");
// An opaque Normal style applied to an opaque mark replaces the opacity another mark of the series left.
let (mut s, id, _, marks) = column_marks();
s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 40})).unwrap();
blue_style(&mut s, "Blue", true);
s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[1].0]})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].opacity, None);
}
/// The colors of series `index`'s marks: each mark's top fill and stroke paint and its stroke weight.
fn marks_of(s: &Session, id: NodeId, index: u32) -> Vec<(Paint, Paint, f64)> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
@@ -1010,19 +1202,46 @@ fn a_stroke_set_on_a_filled_line_band_does_not_repaint_it() {
assert_eq!(redrawn_band(&mut s, id), (was, Paint::solid(vectorcraft_color::Color::BLACK), 0.25));
}
/// An opacity set on a Filled Lines band's stroke row is kept, without a stroke paint, and goes on the band's thin black
/// outline when the graph is drawn again.
/// An opacity and blend mode set on a Filled Lines band's stroke row are kept and drawn on its outline, which keeps its
/// black paint and 0.25 pt weight.
#[test]
fn a_stroke_opacity_set_on_a_filled_line_band_goes_on_its_outline() {
fn a_filled_line_bands_outline_keeps_a_stroke_opacity_and_blend_mode() {
let (mut s, id, band) = band_graph();
let row = top_row(&s, band, false);
s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "opacity": 30})).unwrap();
let kept = spec(&s, id).series_paints.first().cloned().unwrap_or_default();
assert_eq!((kept.stroke.is_none(), kept.stroke_opacity), (true, Some(0.3)));
s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "opacity": 50, "blend": "multiply"})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.stroke, kept.stroke_opacity, kept.stroke_blend), (None, Some(0.5), Some(BlendMode::Multiply)));
let (_, outline, width) = redrawn_band(&mut s, id);
assert_eq!((outline, width), (Paint::solid(vectorcraft_color::Color::BLACK), 0.25));
let band = series_parts(&s, id, 0)[0].clone();
assert_eq!(band.appearance.stroke().unwrap().opacity, 0.3);
let st = band.appearance.stroke().unwrap();
assert_eq!((st.opacity, st.blend), (0.5, BlendMode::Multiply));
}
/// A graphic style without a stroke applied to a marker whose stroke had paint replaces the stroke opacity and blend mode
/// the series' Filled Lines band left: the band's outline is drawn opaque and Normal again. A marker that had no stroke
/// before the style leaves them as they are.
#[test]
fn a_style_that_removes_a_marks_stroke_replaces_the_kept_stroke_opacity() {
for stroked in [true, false] {
let (mut s, id, band) = band_graph();
let marker = series_parts(&s, id, 0)[1].id;
if stroked {
let row = top_row(&s, marker, false);
s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": row, "color": "#ff0000"})).unwrap();
}
let row = top_row(&s, band, false);
s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "opacity": 40, "blend": "multiply"})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].stroke_opacity, Some(0.4));
blue_style(&mut s, "Blue", false);
s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marker.0]})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
let want = if stroked { (None, None) } else { (Some(0.4), Some(BlendMode::Multiply)) };
assert_eq!((kept.stroke_opacity, kept.stroke_blend), want, "stroked {stroked}");
redrawn_band(&mut s, id);
let st = series_parts(&s, id, 0)[0].appearance.stroke().unwrap().clone();
assert_eq!((Some(st.opacity).filter(|o| *o < 1.0), Some(st.blend).filter(|b| *b != BlendMode::Normal)), want, "stroked {stroked}");
}
}
/// A Filled Lines band's stroke row removed with the Appearance panel or set to None with `paint.setStroke` leaves the
@@ -1307,13 +1526,15 @@ fn a_paint_on_a_design_copy_or_the_other_kind_of_row_is_not_kept() {
s.execute("appearance.setItem", &json!({"ids": [line.id.0], "index": row, "color": "#0000ff"})).unwrap();
assert!(spec(&s, id).series_paints[0].fill.is_none(), "a stroke edit on a line keeps no fill");
// A blend mode on another mark of the series leaves the stored paint as that mark's own paint differs from it.
// A blend mode on another mark of the series leaves the stored paint as that mark's own paint differs from it, and
// stores only the blend mode.
let (mut s, id, _, marks) = column_marks();
let fill = top_row(&s, marks[0], true);
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap();
let kept = spec(&s, id);
let mut kept = spec(&s, id).series_paints;
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": fill, "blend": "multiply"})).unwrap();
assert_eq!(spec(&s, id), kept);
kept[0].fill_blend = Some(vectorcraft_color::BlendMode::Multiply);
assert_eq!(spec(&s, id).series_paints, kept);
}
/// A graphic style `name` made from a new rectangle with a blue fill at 50% opacity, and without a stroke row unless
+1 -1
View File
@@ -206,7 +206,7 @@ pub(crate) fn whole_or(p: &Value, key: &str, default: u64, cmd: &str) -> Result<
/// Run a command's implementation without the enablement check or journaling.
pub(crate) fn run_raw(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
let c = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
(c.run)(s, p)
super::run_spec(s, c, p)
}
/// Merge the undo entries pushed since the history had `from` entries into one labelled step.
+1 -1
View File
@@ -251,7 +251,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Object Properties",
[],
None,
"{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}",
"{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}. On a path directly in a graph series, the graph also stores the object's opacity and blend mode (exceptions: docs/mcp.md)",
has_doc,
set_props
),
+8 -1
View File
@@ -65,7 +65,11 @@ pub(super) fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
})
};
let d = &s.doc()?.doc;
if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) {
// A graph keeps the object opacity and blend mode set on a mark of a series for the whole series, so a value a mark
// already has still replaces the one another mark left on the graph.
let transparency = opacity.is_some() || blend.is_some();
let stores = || transparency && crate::cmd::graph::series_object_opacity_differs(d, &ids);
if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) && !stores() {
// Hidden or locked objects can't stay selected.
if hides {
s.select(|d, sel| sel.deselect_uneditable(d))?;
@@ -118,6 +122,9 @@ pub(super) fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
});
}
}
if transparency {
crate::cmd::graph::capture_series_object_opacity(d, &ids);
}
// Hidden or locked objects can't stay selected.
if hides {
sel.deselect_uneditable(d);
+4 -4
View File
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Default Fill and Stroke",
[],
Some("D"),
"{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
"{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
has_doc,
default_paint
),
@@ -73,7 +73,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Transparency",
["Window", "Transparency"],
None,
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself)",
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). On a path directly in a graph series, the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
has_doc,
transparency
),
@@ -855,7 +855,7 @@ fn swap(s: &mut Session, p: &Value) -> Result<Value> {
n.appearance.set_stroke(f);
}
// The swap writes the paint of the topmost fill and stroke, not their opacity.
let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (false, false), true);
let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (false, false), true, false);
Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept))
})?;
}
@@ -884,7 +884,7 @@ fn default_paint(s: &mut Session, p: &Value) -> Result<Value> {
_ => n.appearance = Appearance::default_art().into(),
}
}
let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false);
let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, false);
Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept))
})?;
}
File diff suppressed because one or more lines are too long
+98 -4
View File
@@ -6,7 +6,9 @@
//! by `|`; a `?` marks an optional one, and a `:`, `=` or `(` starts what the entry says about it. An entry that starts
//! with `…` stands for keys listed elsewhere: those of each command it names after `as` or `of`
//! (`…settings as file.new`), or, naming none, any key (the options of the format an export picks). Objects inside a value aren't
//! described key by key, so only the top level is checked.
//! described key by key, so only the top level is checked, except in an export's options: those
//! are checked against the options of the formats the command can write, in `marks`, `bleed` and
//! the other objects too ([`check_export_options`]).
//!
//! [`Session::execute`] answers a key the description doesn't name with a parameter error
//! ([`check_params`]), for every caller (the app, the control channel, MCP, the CLI) and for the
@@ -500,9 +502,100 @@ pub const UNCHECKED: &[&str] = &[
"webExport.settings",
];
/// [`check_keys`] for an engine command, the steps of a `command.batch` or an `action.batch` included, and
/// [`check_effect_params`] for the effect `effect.apply` adds. A command the engine doesn't have
/// passes: running it reports that.
/// The object options of a PDF export, key by key: the Save PDF settings as a preset stores them,
/// with the passwords it leaves out.
fn pdf_option_tree() -> Value {
let mut tree = serde_json::to_value(vectorcraft_pdf::PdfSettings::default()).unwrap_or_default();
if let Some(security) = tree.get_mut("security").and_then(Value::as_object_mut) {
security.extend(["openPassword", "permissionsPassword"].map(|k| (k.to_string(), Value::Null)));
}
tree
}
/// `given`, the object at `within` (`marks.`), has only keys of `schema`, the objects inside it too.
fn check_option_tree(id: &str, within: &str, given: &serde_json::Map<String, Value>, schema: &serde_json::Map<String, Value>) -> Result<()> {
for (key, value) in given {
match (schema.get(key), value) {
(None, _) => {
let keys: Vec<&str> = schema.keys().map(String::as_str).collect();
let why = match nearest(key, &keys) {
Some(near) => format!("did you mean `{within}{near}`?"),
None => format!("`{}` takes: {}", within.trim_end_matches('.'), keys.join(", ")),
};
return Err(bad(id, format!("unknown parameter `{within}{key}`; {why}")));
}
(Some(Value::Object(inner)), Value::Object(value)) => check_option_tree(id, &format!("{within}{key}."), value, inner)?,
_ => {}
}
}
Ok(())
}
/// The options of an export command `id`. `document.export`, `document.exportSelection` and
/// `document.serialize` take a key that some format reads (one object can carry several formats'
/// options; `document.formats` lists them). `document.exportPdf`, `document.pdfSettings` and
/// `pdf.preset.save` take a key that PDF reads, `document.exportEps` one that EPS reads and
/// `document.exportDxf` one that DXF reads. Where a command takes `marks`, `bleed`, `flattener` or
/// another object option of PDF or EPS, only the object's own keys pass. An SVG export's `svg`
/// object is checked when it is read. Saved presets are not checked: one with a key that is no
/// longer read keeps loading.
fn check_export_options(id: &str, params: &Value) -> Result<()> {
use super::fileio::{FORMATS, dxf, eps, pdf};
let Value::Object(given) = params else { return Ok(()) };
let mut keys: Vec<&str> = vec!["path", "format", "acknowledgeLoss"];
let why_not = match id {
"document.export" | "document.exportSelection" | "document.serialize" => {
// `richBlack` is Appearance of Black's setting for a raster export, and `params` an
// exporter plug-in's own.
keys.extend(["includeArtboardFill", "selectedOnly", "richBlack", "params"]);
keys.extend(FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).map(|o| o.name));
"no format takes it"
}
"document.exportPdf" | "document.pdfSettings" | "pdf.preset.save" => {
keys.push("includeArtboardFill");
keys.extend(pdf::OPTIONS.iter().map(|o| o.name));
match id {
"document.pdfSettings" => keys.push("includeDocument"),
"pdf.preset.save" => keys.extend(["name", "newName", "description"]),
_ => {}
}
"PDF doesn't take it"
}
"document.exportEps" => {
keys.push("includeArtboardFill");
keys.extend(eps::OPTIONS.iter().map(|o| o.name));
"EPS doesn't take it"
}
"document.exportDxf" => {
keys.extend(dxf::OPTIONS.iter().map(|o| o.name));
"DXF doesn't take it"
}
_ => return Ok(()),
};
let mut trees = pdf_option_tree();
if let Some(tree) = trees.as_object_mut() {
tree.insert("flattener".into(), serde_json::to_value(super::flatten::FlattenOptions::default()).unwrap_or_default());
}
for (key, value) in given {
if !keys.contains(&key.as_str()) {
return Err(bad(
id,
match nearest(key, &keys) {
Some(near) => format!("unknown parameter `{key}`; did you mean `{near}`?"),
None => format!("unknown parameter `{key}`; {why_not} (see document.formats)"),
},
));
}
if let (Some(Value::Object(schema)), Value::Object(value)) = (trees.get(key), value) {
check_option_tree(id, &format!("{key}."), value, schema)?;
}
}
Ok(())
}
/// [`check_keys`] for an engine command, the steps of a `command.batch` or an `action.batch` included,
/// [`check_effect_params`] for the effect `effect.apply` adds and [`check_export_options`] for an
/// export. A command the engine doesn't have passes: running it reports that.
pub fn check_params(id: &str, params: &Value) -> Result<()> {
check_params_in(id, params, false)
}
@@ -516,6 +609,7 @@ fn check_params_in(id: &str, params: &Value, in_batch: bool) -> Result<()> {
{
check_effect_params(id, effect, given)?;
}
check_export_options(id, params)?;
if UNCHECKED.contains(&id) {
return Ok(());
}
+57 -2
View File
@@ -14,6 +14,7 @@ use vectorcraft_pathops as po;
use vectorcraft_pathops::{BoolOp, PathfinderOp};
use super::edit::selected_roots;
use super::expand::{fill_shape, reshaped, seeded};
use super::{
CommandSpec, EngineError, Result, Session, always, bad, bool_or, checked_ids_or_selection, cmd, has_doc, has_selected_path, has_selection,
number_or, str_param, switch_or,
@@ -891,24 +892,60 @@ pub(crate) fn outlined_stroke(
/// Path `l` with every stroke outlined, in appearance order: runs of fills stay on copies of
/// the path, each stroke becomes its outline (or brush art), grouped when there are several.
/// As on the canvas, the path's geometry effects ([`reshaped`]) shape the path first, and each
/// stroke's own geometry effects then shape the path that stroke outlines; [`outlined_stroke`]
/// gets the stroke without them. A fill with a random Transform effect paints the outline its
/// geometry effects make of the path, on a copy of its own. A path whose Transform effect copies
/// it with a gradient, or has Scale Strokes & Effects or Transform Patterns on, becomes the art the
/// canvas draws for it, each copy with its own gradient, stroke width and pattern, and its strokes
/// outlined before the art gets new ids. Random in a Transform effect takes its seed
/// from the object's id, so these effects give the outline the values the canvas drew. When the
/// geometry effects leave nothing to paint, an empty group takes the path's place.
/// `None` when `l` has no stroke to outline.
pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &Node) -> Option<Node> {
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_transform_art, is_geometry, reshape};
if !l.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st))) {
return None;
}
if has_transform_art(l) {
let mut art = reshape(Some(&*d), l, None)?;
outline_member_strokes(d, brushes, &mut art);
return Some(d.reid(&art));
}
let reshaped = reshaped(l);
let l = reshaped.as_ref().unwrap_or(l);
let (path, rule) = node_path(l)?;
let ctx = GeomContext::of(l);
// A fill belongs to the source path, whether open or closed. Converting its stroke
// preserves that fill on a separate copy (including compound member geometry).
let mut parts: Vec<Node> = vec![];
let mut fills: Vec<AppearanceItem> = vec![];
for item in &l.appearance.items {
match item {
AppearanceItem::Fill(f) if f.visible && !f.paint.is_none() => fills.push(item.clone()),
AppearanceItem::Fill(f) if f.visible && !f.paint.is_none() => {
let shaped = f.effects.iter().any(seeded).then(|| path.bounds().and_then(|b| fill_shape(l, item, &path, b))).flatten();
let Some((shape, _, _, moved)) = shaped else {
fills.push(item.clone());
continue;
};
if !fills.is_empty() {
parts.push(styled_copy(d, l, std::mem::take(&mut fills)));
}
let mut plain = moved;
plain.effects_mut().retain(|e| !is_geometry(&e.id));
parts.push(styled_copy(d, &shape, vec![plain]));
}
AppearanceItem::Stroke(st) if outlinable(st) => {
if !fills.is_empty() {
parts.push(styled_copy(d, l, std::mem::take(&mut fills)));
}
parts.extend(outlined_stroke(d, brushes, &path, rule, st));
let mut plain = st.clone();
plain.effects.retain(|e| !is_geometry(&e.id));
let stroked = match path.bounds() {
Some(b) if has_geometry(&st.effects) => apply_geometry_with(&st.effects, &path, b, &ctx.item(item)),
_ => path.clone(),
};
parts.extend(outlined_stroke(d, brushes, &stroked, rule, &plain));
}
_ => {}
}
@@ -916,9 +953,27 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br
if !fills.is_empty() {
parts.push(styled_copy(d, l, fills));
}
let reshapes = reshaped.is_some()
|| l.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st) && has_geometry(&st.effects)));
if parts.is_empty() && reshapes {
// The geometry effects leave nothing to paint, as on the canvas: an empty group takes the path's place.
parts.push(Node::group(d.alloc_id(), vec![]));
}
assemble(d, l, parts)
}
/// Outline the strokes of every member of group `n`, however deep ([`outline_strokes`]).
fn outline_member_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &mut Node) {
let Some(children) = n.children_mut() else { return };
for c in children.iter_mut() {
if let Some(o) = outline_strokes(d, brushes, c) {
*c = Arc::new(o);
} else if c.children().is_some() {
outline_member_strokes(d, brushes, Arc::make_mut(c));
}
}
}
/// A plain copy of path `l` (new ids, no transparency, name or mask) painted with `items`.
/// The copy is shown and unlocked.
pub(crate) fn styled_copy(d: &mut Document, l: &Node, items: Vec<AppearanceItem>) -> Node {
+1 -1
View File
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save PDF Preset",
[],
None,
"{name?: (default: a new \"PDF Preset N\"), newName?: rename it, description?, preset?: the preset to start from (default: the saved preset `name`, else VectorCraft Default), …options of document.exportPdf} create or change a saved preset (built-in presets are read-only; passwords are never stored) → {name, created, settings}",
"{name?: (default: a new \"PDF Preset N\"), newName?: rename it, description?, preset?: the preset to start from (default: the saved preset `name`, else VectorCraft Default), …options of document.exportPdf} create or change a saved preset (built-in presets are read-only; passwords are never stored) → {name, created, settings}. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused",
always,
save
),
+193 -11
View File
@@ -20,6 +20,13 @@
//! `doc.write` an output that changes the document is refused, and without `state` it neither
//! reads nor writes the data it keeps with the document.
//!
//! A filter or a live effect may add menu commands of its own (its manifest's `commands`). Each
//! is the command `plugin.<plug-in id>.<command id>` while the plug-in is installed
//! ([`command_specs`], which [`super::find_command`] and the command listings read): under
//! Object › Plug-ins › the plug-in's name (Effect › Plug-ins for a live effect's), journaled so
//! actions record it, and run as a filter run of the plug-in ([`run_command`]) with the command's
//! id in `_context`, its own checked parameters and the plug-in's permissions, as one undo step.
//!
//! A plug-in of API level 2 that lists `progress` may import the host's `vc_progress`,
//! `vc_cancelled` and `vc_log` ([`vectorcraft_plugins::host`]): run inside a
//! [`vectorcraft_doc::cancel::scope`] (the app's heavy filters and background exports), its
@@ -29,11 +36,11 @@
//! `plugin.clearCache` empties the cache, `plugin.list` and `plugin.info` give its size
//! (`cacheBytes`), replacing the plug-in keeps both and `plugin.remove` deletes them.
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, RwLock};
use serde_json::{Map, Value, json};
use vectorcraft_doc::{Effect, Node};
use vectorcraft_plugins::{Budget, Kind, Plugin, effect, export, objects, registry, safe_mode};
use vectorcraft_plugins::{Budget, Command, Kind, Plugin, effect, export, objects, registry, safe_mode};
use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, has_doc, ids_param, str_param, switch_or};
@@ -67,6 +74,18 @@ pub fn describe(p: &Plugin) -> Value {
if m.budget == Budget::Heavy {
v["budget"] = json!("heavy");
}
if !m.commands.is_empty() {
let commands: Vec<Value> = m
.commands
.iter()
.map(|c| {
let run = m.for_command(c);
json!({"id": c.id, "command": command_id(&m.id, &c.id), "label": c.shown_label(), "params": run.params_doc(),
"paramsSchema": serde_json::to_value(&run).ok().and_then(|v| v.get("params").cloned())})
})
.collect();
v["commands"] = json!(commands);
}
v
}
@@ -97,6 +116,8 @@ fn plugin_by_id(cmd: &str, p: &Value) -> Result<Arc<Plugin>> {
/// like any other command.
pub struct FilterRun {
plugin: Arc<Plugin>,
/// The plug-in command it runs (`plugin.<id>.<command>`), if it isn't `plugin.run`'s run.
command: Option<Command>,
input: Vec<u8>,
params: Vec<u8>,
/// The objects the plug-in is given, and the container new objects go in.
@@ -138,13 +159,23 @@ impl FilterRun {
));
}
}
// Bad parameters fail before anything runs.
let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?);
Self::prepare_with(s, plugin, None, C, p)
}
/// The run of `plugin`, or of its `command`, that command `c`'s params `p` ask for, on the
/// active document. Bad parameters fail before anything runs.
fn prepare_with(s: &Session, plugin: Arc<Plugin>, command: Option<Command>, c: &str, p: &Value) -> Result<FilterRun> {
let m = plugin.manifest();
let resolved = match &command {
Some(cmd) => m.for_command(cmd).resolve_params(&plugin_params(p)),
None => m.resolve_params(&plugin_params(p)),
};
let mut params = Value::Object(resolved.map_err(|e| bad(c, e.to_string()))?);
let may = m.permissions();
let st = s.doc()?;
// While an object of a dynamic symbol instance's art is selected, the selection is refused,
// as `selected_roots` refuses it.
let selected = match ids_param(p, "ids", C)? {
let selected = match ids_param(p, "ids", c)? {
Some(ids) => ids,
None => {
super::edit::refuse_symbol_part(s)?;
@@ -160,10 +191,15 @@ impl FilterRun {
// What the plug-in keeps with the document, as it last wrote it.
let data = st.doc.plugin_data(&m.id).filter(|_| may.contains(&Permission::State)).cloned().unwrap_or(Value::Null);
params["_context"] = json!({"mode": "filter", "bounds": bounds.map(rect), "artboard": board.map(|a| rect(a.rect)), "documentData": data});
// A command run says which command it is.
if let Some(cmd) = &command {
params["_context"]["mode"] = json!("command");
params["_context"]["command"] = json!(cmd.id);
}
let input = objects::encode_input(&st.doc, &inputs);
let params = serde_json::to_vec(&params).map_err(|e| bad(C, e.to_string()))?;
let params = serde_json::to_vec(&params).map_err(|e| bad(c, e.to_string()))?;
let document = (st.uid, st.revision);
Ok(FilterRun { plugin, input, params, inputs, parent, document })
Ok(FilterRun { plugin, command, input, params, inputs, parent, document })
}
/// The plug-in, for its name and budget.
@@ -214,7 +250,9 @@ impl FilterRun {
}
let may_write = may.contains(&Permission::DocWrite);
let (inputs, parent) = (self.inputs, self.parent);
let ids = s.edit(m.name.trim_end_matches('…'), |d, sel| {
// A command's undo step is named after the command.
let label = self.command.as_ref().map_or_else(|| m.name.trim_end_matches('…').to_string(), Command::shown_label);
let ids = s.edit(&label, |d, sel| {
let before = (!may_write).then(|| d.layers.clone());
let ids = objects::apply_output(d, &inputs, out.objects, parent, &admit_effects).map_err(err)?;
if before.is_some_and(|b| b != d.layers) {
@@ -226,7 +264,11 @@ impl FilterRun {
sel.set(ids.iter().copied().filter(|&id| d.node(id).is_some_and(objects::selectable)));
Ok(ids)
})?;
Ok(json!({"plugin": m.id, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
let mut out = json!({"plugin": m.id, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()});
if let Some(cmd) = &self.command {
out["command"] = json!(cmd.id);
}
Ok(out)
}
}
@@ -256,6 +298,146 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
r.finish(s, &output)
}
/// The command id of plug-in `plugin`'s command `command`: `plugin.<plugin>.<command>`.
pub fn command_id(plugin: &str, command: &str) -> String {
format!("{}{plugin}.{command}", vectorcraft_plugins::PREFIX)
}
/// Is `id` shaped as a plug-in command's id (`plugin.<plug-in id>.<command id>`)? This module's
/// own `plugin.*` commands have no dot after the prefix, and a command id has none.
pub fn is_command_id(id: &str) -> bool {
id.strip_prefix(vectorcraft_plugins::PREFIX).is_some_and(|rest| rest.contains('.'))
}
/// The installed plug-in and its command that command id `id` names.
pub fn command_of(id: &str) -> Option<(Arc<Plugin>, Command)> {
let (plugin, command) = id.strip_prefix(vectorcraft_plugins::PREFIX)?.rsplit_once('.')?;
let p = registry::get(plugin)?;
let c = p.manifest().command(command)?.clone();
Some((p, c))
}
/// The plug-in commands of the installed plug-ins, the registry revision they were listed at, and
/// a count that changes whenever the list does ([`commands_generation`]).
struct Listed {
revision: Option<u64>,
generation: u64,
specs: Vec<&'static CommandSpec>,
}
static LISTED: RwLock<Listed> = RwLock::new(Listed { revision: None, generation: 0, specs: Vec::new() });
/// Every command spec made for a plug-in command, made once: specs hold static strings, so memory
/// stays bounded by the plug-in commands ever installed.
static MADE: Mutex<Vec<&'static CommandSpec>> = Mutex::new(Vec::new());
/// The commands the installed plug-ins add, in plug-in id order and then in each manifest's order.
pub fn command_specs() -> Vec<&'static CommandSpec> {
let revision = registry::revision();
if let Ok(l) = LISTED.read()
&& l.revision == Some(revision)
{
return l.specs.clone();
}
let specs: Vec<&'static CommandSpec> =
registry::list().iter().flat_map(|p| p.manifest().commands.iter().map(|c| spec_of(p, c)).collect::<Vec<_>>()).collect();
// When a plug-in was installed or removed during the listing, the list may not match
// `revision`. It is returned without being cached, and the next call lists again.
if registry::revision() != revision {
return specs;
}
let mut l = LISTED.write().unwrap_or_else(|e| e.into_inner());
if l.specs.len() != specs.len() || l.specs.iter().zip(&specs).any(|(a, b)| !std::ptr::eq(*a, *b)) {
l.generation += 1;
}
l.revision = Some(revision);
l.specs.clone_from(&specs);
specs
}
/// Changes whenever the plug-in commands [`command_specs`] lists change, for caches built from
/// them (the keyboard shortcuts' catalogue).
pub fn commands_generation() -> u64 {
command_specs();
LISTED.read().map(|l| l.generation).unwrap_or_else(|e| e.into_inner().generation)
}
/// The spec of installed plug-in command `id`.
pub fn find_spec(id: &str) -> Option<&'static CommandSpec> {
if !is_command_id(id) {
return None;
}
command_specs().into_iter().find(|c| c.id == id)
}
/// The command spec of plug-in `p`'s command `c`: its id, its label as menus show it (escaped and
/// capped), its menu (Object › Plug-ins › the plug-in's name, or Effect › Plug-ins › it for a live
/// effect: never a menu the manifest names) and its params description.
fn spec_of(p: &Plugin, c: &Command) -> &'static CommandSpec {
let m = p.manifest();
let id = command_id(&m.id, &c.id);
let label = c.shown_label();
let top = if m.kind == Kind::Effect { "Effect" } else { "Object" };
let name = permissions::display_text(m.name.trim(), permissions::MAX_SHOWN_NAME);
let mut fields =
String::from("ids?: [object id] (left out or null: the selection), params?: {…} (its parameters; or give them as top-level keys)");
let doc = m.for_command(c).params_doc();
if let Some(own) = doc.strip_prefix('{').and_then(|d| d.strip_suffix('}')).filter(|d| !d.is_empty()) {
fields.push_str(", ");
fields.push_str(own);
}
let params = format!(
"{{{fields}}} run “{label}”, a command of plug-in {}, on the selected paths and compound paths as plugin.run runs a filter, with _context.command {:?}: one undo step → {{plugin, command, ids}}",
m.id, c.id
);
let mut made = MADE.lock().unwrap_or_else(|e| e.into_inner());
let same = |s: &&&'static CommandSpec| s.id == id && s.label == label && s.menu == [top, "Plug-ins", name.as_str()] && s.params == params;
if let Some(s) = made.iter().find(same) {
return s;
}
let menu: &'static [&'static str] = Box::leak(vec![top, "Plug-ins", registry::intern(&name)].into_boxed_slice());
let spec: &'static CommandSpec = Box::leak(Box::new(CommandSpec {
id: registry::intern(&id),
label: registry::intern(&label),
menu,
shortcut: None,
params: registry::intern(&params),
enabled: has_doc,
run: unrouted,
journal: true,
}));
made.push(spec);
spec
}
/// A plug-in command's `run`: plug-in commands run through [`super::run_spec`], which passes
/// [`run_command`] the command id.
fn unrouted(_: &mut Session, _: &Value) -> Result<Value> {
Err(EngineError::Internal { cmd: "plugin".into(), msg: "a plug-in command runs through Session::execute".into() })
}
/// Runs plug-in command `id` (`plugin.<plug-in id>.<command id>`): a filter run of the plug-in on
/// `ids` (else the selection) with the command's own parameters, checked before it runs, and
/// `_context.command`, under the plug-in's permissions and budget, applied as one undo step named
/// after the command → `{plugin, command, ids}`. A run that fails (a trap, an exhausted budget, an
/// output it may not make) leaves the document as it was, and the error names the plug-in and the
/// command.
pub(crate) fn run_command(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
// An output handed in is for `plugin.run` alone.
s.plugin_output = None;
let (plugin, command) = command_of(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
let failed = {
let (name, label) = (permissions::display_text(&plugin.manifest().name, permissions::MAX_SHOWN_NAME), command.shown_label());
move |e: EngineError| match e {
EngineError::Cancelled => EngineError::Cancelled,
e => EngineError::Other(format!("“{name}” › {label}: {e}")),
}
};
let r = FilterRun::prepare_with(s, plugin, Some(command), id, p)?;
let output = r.run().map_err(&failed)?;
r.finish(s, &output).map_err(failed)
}
/// Admits the effect records a filter returned for object `n` (written, its effects still its own)
/// as `effect.apply` would apply them: each new record a known effect (one of ours, or an installed
/// live-effect plug-in) with parameters it takes, filled in to their defaults. A record the object
@@ -677,7 +859,7 @@ pub fn specs() -> Vec<CommandSpec> {
"List Plug-ins",
[],
None,
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's format id, plugin.<id>, for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), cacheBytes (the bytes its cache holds; see plugin.clearCache)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's format id, plugin.<id>, for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.<id>.<command id>, which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
always,
list
),
@@ -695,7 +877,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Run Plug-in",
[],
None,
"{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}",
"{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [object id] (left out or null: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}",
has_doc,
run
),
+5 -5
View File
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Graphic Style",
["Window", "Graphic Styles"],
None,
"{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows; exceptions: docs/mcp.md). With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}",
"{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions: docs/mcp.md). With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}",
has_doc,
style_apply
),
@@ -67,7 +67,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Redefine Graphic Style",
["Window", "Appearance"],
None,
"{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object; the objects still linked to it update, and that object becomes linked. On a linked path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {name}",
"{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object; the objects still linked to it update, and that object becomes linked. On a linked path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {name}",
has_doc,
style_redefine
),
@@ -349,14 +349,14 @@ fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
// The graph stores the rows of the kinds the style shows, now on top of each stack.
let shows = |kind: bool| g.appearance.items.iter().any(|it| it.is_fill() == kind && it.visible());
let kinds = (shows(true), shows(false));
super::appearance::capture_series_writes(&before, d, &ids, kinds, kinds, false);
super::appearance::capture_series_writes(&before, d, &ids, kinds, kinds, false, false);
} else {
d.graphic_style_id(i);
let g = d.graphic_styles[i].clone();
for id in &ids {
apply_style(d, *id, &g, chars);
}
super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false);
super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, true);
}
Ok(())
})?;
@@ -476,7 +476,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result<Value> {
for nid in &follow {
apply_style(d, *nid, &g, chars);
}
super::appearance::capture_series_writes(&before, d, &follow, (true, true), (true, true), false);
super::appearance::capture_series_writes(&before, d, &follow, (true, true), (true, true), false, true);
set_link(d, &[src], Some(id));
Ok(())
})?;
+2
View File
@@ -1604,6 +1604,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
path_spacing: 0.0,
wrap: Vec::new(),
optical_margin: false,
anti_alias: Default::default(),
system_layout: false,
fractional_widths: true,
list_count: 0,
@@ -1893,6 +1894,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
path_spacing: 0.0,
wrap: Vec::new(),
optical_margin: false,
anti_alias: Default::default(),
system_layout: t.system_layout,
fractional_widths: t.fractional_widths,
list_count: 0,
+59
View File
@@ -1134,6 +1134,65 @@ fn a_thread_takes_its_first_frames_optical_margin_alignment() {
assert_eq!([a, b, c].map(|id| optical(&s, id).0), [true; 3]);
}
/// The anti-aliasing method (`text.setFormat {antiAlias}`) belongs to each frame and is saved
/// only when not Sharp. A range sets its frame in one undo step. Threading and opening preserve
/// different methods on the frames of a thread.
#[test]
fn the_anti_aliasing_method_is_set_for_each_frame_of_a_thread() {
use vectorcraft_doc::text::TypeAntiAlias::{self, Crisp, None as Hard, Sharp, Strong};
let method = |s: &Session, id: u64| text_of(&s.doc().unwrap().doc, NodeId(id)).map(|t| t.anti_alias);
let saved = |s: &Session, id: u64| serde_json::to_value(text_of(&s.doc().unwrap().doc, NodeId(id)).unwrap()).unwrap().get("antiAlias").cloned();
let (mut s, a, b) = quoted_thread();
assert_eq!((method(&s, a), method(&s, b)), (Some(Sharp), Some(Sharp)));
assert_eq!(saved(&s, a), None, "Sharp is not saved");
s.execute("select.set", &json!({"ids": [b]})).unwrap();
let err = s.execute("text.setFormat", &json!({"antiAlias": "smooth"})).unwrap_err().to_string();
assert!(err.contains("`antiAlias` must be"), "{err}");
s.execute("text.setFormat", &json!({"antiAlias": "crisp"})).unwrap();
assert_eq!((method(&s, a), method(&s, b)), (Some(Sharp), Some(Crisp)), "only the selected frame takes it");
assert_eq!(saved(&s, b), Some(json!("crisp")));
// A range of characters sets its whole frame.
s.execute("text.setFormat", &json!({"ids": [a], "start": 0, "end": 1, "antiAlias": "none"})).unwrap();
assert_eq!((method(&s, a), method(&s, b)), (Some(Hard), Some(Crisp)));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((method(&s, a), method(&s, b)), (Some(Sharp), Some(Crisp)), "one undo step");
// A new frame does not change the methods of the existing frames.
s.execute("text.setFormat", &json!({"ids": [b], "antiAlias": "strong"})).unwrap();
let c = new_id(s.execute("text.thread.add", &json!({"from": b, "at": [400, 10]})).unwrap());
assert_eq!([a, b, c].map(|id| method(&s, id)), [Some(Sharp), Some(Strong), Some(Strong)]);
s.execute("edit.undo", &json!({})).unwrap();
// Threading existing frames with different methods preserves both.
let (mut joined, first_id, second_id) = quoted_thread();
joined.execute("text.thread.remove", &json!({})).unwrap();
joined.execute("text.setFormat", &json!({"ids": [first_id], "antiAlias": "crisp"})).unwrap();
joined.execute("text.setFormat", &json!({"ids": [second_id], "antiAlias": "strong"})).unwrap();
joined.execute("select.set", &json!({"ids": [first_id, second_id]})).unwrap();
joined.execute("text.thread.create", &json!({})).unwrap();
assert_eq!((method(&joined, first_id), method(&joined, second_id)), (Some(Crisp), Some(Strong)));
joined.execute("text.editRange", &json!({"id": first_id, "start": 0, "end": 0, "insert": "New "})).unwrap();
assert_eq!((method(&joined, first_id), method(&joined, second_id)), (Some(Crisp), Some(Strong)), "reflow keeps both methods");
joined.execute("text.thread.remove", &json!({})).unwrap();
assert_eq!((method(&joined, first_id), method(&joined, second_id)), (Some(Crisp), Some(Strong)), "unthreading keeps both methods");
let first: [TypeAntiAlias; 2] = [Crisp, Hard];
for (one, name) in [(first, "old.vectorcraft"), ([Hard, Crisp], "old.vectorcraft"), (first, "old.eps"), ([Hard, Crisp], "old.eps")] {
for (id, m) in [(a, one[0]), (b, one[1])] {
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") };
t.anti_alias = m;
}
let bytes = if name.ends_with(".eps") {
let r = s.execute("document.exportEps", &json!({})).unwrap();
vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()
} else {
vectorcraft_format::save(&s.doc().unwrap().doc, false)
};
let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
assert_eq!(r["restored"], name.ends_with(".eps"), "{r}");
assert_eq!(threads(&s), [[a, b]], "{name}");
assert_eq!((method(&s, a), method(&s, b)), (Some(one[0]), Some(one[1])), "{name}, frame methods");
}
}
/// A file saved with the frames of one thread set differently opens with the first frame's
/// setting in every frame: a native file, and an EPS file restored from the document it carries.
#[test]
+8 -3
View File
@@ -25,9 +25,10 @@ pub const FIT_ACTIVE_ARTBOARD: [&str; 2] = ["artboard.fitToArt", "artboard.fitTo
/// `(command, param)` pairs the journal records only so a replay reproduces the original run
/// (dates from the clock: `cmd::clock_date`; the artboards the app passes to the
/// [`ON_ACTIVE_ARTBOARD`] and [`FIT_ACTIVE_ARTBOARD`] commands and to a batch; the artboards the
/// Artboard tool had selected for its commands; the view's center the app passes to Edit Symbol);
/// an action leaves them out ([`Session::journal_for_action`]).
const REPLAY_ONLY: [(&str, &str); 22] = [
/// Artboard tool had selected for its commands; the artboards and art Cut and Copy took with that
/// tool chosen; the view's center the app passes to Edit Symbol); an action leaves them out
/// ([`Session::journal_for_action`]).
const REPLAY_ONLY: [(&str, &str); 26] = [
("file.new", "created"),
("edit.newDocumentFromState", "created"),
("document.save", "modified"),
@@ -46,6 +47,10 @@ const REPLAY_ONLY: [(&str, &str); 22] = [
("artboard.cut", "selectedArtboards"),
("artboard.rename", "selectedArtboards"),
("artboard.switchOrientation", "selectedArtboards"),
("edit.copy", "selectedArtboards"),
("edit.copy", "art"),
("edit.cut", "selectedArtboards"),
("edit.cut", "art"),
("select.allOnArtboard", "artboard"),
("symbol.edit", "artboard"),
("symbol.edit", "viewCenter"),
+3 -2
View File
@@ -344,9 +344,10 @@ impl Session {
true
}
/// Commands with enablement (for menus, palette, MCP `list_commands`).
/// Commands with enablement (for menus, palette, MCP `list_commands`), the installed plug-ins'
/// commands last.
pub fn commands(&self) -> Vec<CommandInfo> {
command_specs().iter().map(|c| c.info(self)).collect()
command_specs().iter().chain(crate::cmd::plugin::command_specs()).map(|c| c.info(self)).collect()
}
}
+7 -3
View File
@@ -76,9 +76,13 @@ impl Session {
cmd::ids_param(params, "ids", id)?;
}
// Clear's menu needs a selection; an agent supplying explicit targets only needs a
// document. The command validates those targets before making any edit.
// document. The command validates those targets before making any edit. Cut and Copy given
// the artboards the journal recorded need only a document too, whatever is selected and
// whichever tool is chosen.
let given = given_ids(spec.params, params);
let enabled = if id == "edit.clear" && params.get("ids").is_some() {
let enabled = if (id == "edit.clear" && params.get("ids").is_some())
|| (matches!(id, "edit.copy" | "edit.cut") && params.get("selectedArtboards").is_some())
{
cmd::has_doc(self)
} else if let Some(ids) = given.clone() {
// Objects given as `ids` (or `id`) stand in for the selection: the command is enabled
@@ -113,7 +117,7 @@ impl Session {
if let Some(ids) = &select_first {
s.execute("select.set", &serde_json::json!({ "ids": ids }))?;
}
let r = (spec.run)(s, params);
let r = cmd::run_spec(s, spec, params);
if r.is_err()
&& let Some((uid, selection, layer)) = selected_before
&& let Some(st) = s.docs.iter_mut().find(|d| d.uid == uid)
+85
View File
@@ -2969,6 +2969,91 @@ fn image_trace_make_places_paths_over_the_image() {
assert_eq!(paths[0].appearance.fill_paint(), Paint::solid(Color::rgb8(0, 0, 0)));
}
#[test]
fn image_trace_keep_colors_traces_only_the_colors_listed() {
// A light field with a black ring, a red square and a blue bar on it.
let art = vectorcraft_trace::Raster::from_fn(200, 150, |x, y| {
let r = (f64::from(x) + 0.5 - 70.0).hypot(f64::from(y) + 0.5 - 60.0);
if (34.0..=40.0).contains(&r) {
[0, 0, 0, 255]
} else if (130..170).contains(&x) && (30..70).contains(&y) {
[200, 40, 50, 255]
} else if (30..170).contains(&x) && (110..130).contains(&y) {
[40, 80, 200, 255]
} else {
[240, 240, 220, 255]
}
});
let traced = |keep: Value| {
let mut s = session();
add_image(&mut s, &art, Affine::IDENTITY);
let params = json!({"params": {"mode": "color", "palette": "limited", "colors": 4, "keepColors": keep}});
let r = s.execute("imageTrace.makeAndExpand", &params)?;
let g = node(&s, NodeId(r["id"].as_u64().unwrap()));
let fills: Vec<Paint> = g.children().unwrap().iter().map(|n| n.appearance.fill_paint()).collect();
Ok::<_, EngineError>((r["colors"].clone(), fills))
};
let (black, red, blue, light) = (Color::rgb8(0, 0, 0), Color::rgb8(200, 40, 50), Color::rgb8(40, 80, 200), Color::rgb8(240, 240, 220));
let has = |fills: &[Paint], c: Color| fills.contains(&Paint::solid(c));
// Nothing listed: every color.
let (colors, fills) = traced(json!([])).unwrap();
assert_eq!(colors, 4);
assert!([black, red, blue, light].into_iter().all(|c| has(&fills, c)), "{fills:?}");
// The black and the red only.
let (colors, fills) = traced(json!(["#000000", "#c8283c"])).unwrap();
assert_eq!(colors, 2);
assert!(has(&fills, black) && has(&fills, red), "{fills:?}");
assert!(fills.iter().all(|f| *f == Paint::solid(black) || *f == Paint::solid(red)), "{fills:?}");
// A color that is none is refused.
let e = traced(json!(["#000000", "black"])).unwrap_err().to_string();
assert!(e.contains("\"black\"") && e.contains("#rrggbb"), "{e}");
assert!(find_command("imageTrace.make").unwrap().params.contains("keepColors?: [\"#rrggbb\"…]"));
assert!(find_command("imageTrace.makeAndExpand").unwrap().params.contains("warnings: [text…] (as imageTrace.make)"));
}
#[test]
fn image_trace_keep_colors_warns_about_colors_that_match_nothing() {
// A light field with a black ring and a red square on it.
let art = vectorcraft_trace::Raster::from_fn(200, 150, |x, y| {
let r = (f64::from(x) + 0.5 - 70.0).hypot(f64::from(y) + 0.5 - 60.0);
if (34.0..=40.0).contains(&r) {
[0, 0, 0, 255]
} else if (130..170).contains(&x) && (30..70).contains(&y) {
[200, 40, 50, 255]
} else {
[240, 240, 220, 255]
}
});
let traced = |params: Value| {
let mut s = session();
add_image(&mut s, &art, Affine::IDENTITY);
let r = s.execute("imageTrace.makeAndExpand", &json!({ "params": params })).unwrap();
(r["paths"].as_u64().unwrap(), r["warnings"].clone())
};
let color = |keep: Value| traced(json!({"mode": "color", "palette": "limited", "colors": 3, "keepColors": keep}));
// Every color listed is in the image: no warning.
let (paths, warnings) = color(json!(["#000000", "#c8283c"]));
assert!(paths > 0);
assert_eq!(warnings, json!([]));
// No color matches: nothing is traced, and the reply says why.
assert_eq!(
color(json!(["#ff00ff", "#00ffff"])),
(0, json!(["Nothing was traced: none of the colors the trace found matches keepColors (#ff00ff, #00ffff)."]))
);
// One color matches nothing: the others are traced, and that one is named.
let (paths, warnings) = color(json!(["#000000", "#00ffff"]));
assert!(paths > 0);
assert_eq!(warnings, json!(["keepColors: none of the colors the trace found matches #00ffff, so nothing of it is traced."]));
// Ignore Color leaves out the only color listed.
let ignored =
traced(json!({"mode": "color", "palette": "limited", "colors": 3, "keepColors": ["#000000"], "ignoreWhite": true, "ignoreColor": "#000000"}));
assert_eq!(ignored, (0, json!(["Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists."])));
// Flat Logo mode does not use the list.
let (paths, warnings) = traced(json!({"mode": "logo", "keepColors": ["#ff00ff"]}));
assert!(paths > 0);
assert_eq!(warnings, json!(["Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds."]));
}
/// #3098: a trace made away from the command (off the UI thread) and handed in makes the command's
/// own edit, as one undo step; Cancel stops it; once its image has changed it no longer applies.
#[test]
+6 -6
View File
@@ -1440,17 +1440,17 @@ fn spiral_drags_with_alt_and_ctrl_change_its_segments_and_decay() {
};
s.select_tool("spiral", v).unwrap();
let undo = undo_steps(&s);
// Alt from a radius of 100 to 200: 10 segments become 14, one full wind of four quarter turns.
// Alt from a radius of 100 to 200: preserving the inner end adds three full winds.
let (anchors, outer) = spiral(&mut s, Mods { alt: true, ..Mods::default() }, (300.0, 100.0));
assert_eq!(anchors, 15);
assert_eq!(anchors, 23);
assert!(near(outer, Point::new(300.0, 100.0)), "{outer:?}");
assert_eq!(s.tool_options()["segments"], json!(14));
// Ctrl/Cmd from 100 to 50: the decay rises from 80 % to 80 × 2^(1/14) %.
assert_eq!(s.tool_options()["segments"], json!(22));
// Ctrl/Cmd from 100 to 50: the decay rises from 80 % to 80 × 2^(4/22) %.
let (anchors, outer) = spiral(&mut s, Mods { cmd: true, ..Mods::default() }, (150.0, 100.0));
assert_eq!(anchors, 15);
assert_eq!(anchors, 23);
assert!(near(outer, Point::new(150.0, 100.0)), "{outer:?}");
let decay = s.tool_options()["decay"].as_f64().unwrap();
assert!((decay - 80.0 * 2f64.powf(1.0 / 14.0)).abs() < 1e-9, "{decay}");
assert!((decay - 80.0 * 2f64.powf(4.0 / 22.0)).abs() < 1e-9, "{decay}");
assert_eq!(undo_steps(&s) - undo, 2, "one undo step a spiral");
}
+122 -6
View File
@@ -343,23 +343,139 @@ fn artboard_commands_read_the_recorded_selected_artboards() {
}
}
/// `edit.copy` and `edit.cut` run the Artboard tool's copy and take no artboards, so their entries
/// stay as given, apart from the locked-art preference the Artboard tool's copy notes.
/// A Cut or Copy with the Artboard tool replays on the artboards it took in a new session, where
/// another tool is chosen: with nothing selected there, and with an object selected that a Cut or
/// Copy of objects would take. What it pastes is the same.
#[test]
fn edit_copy_and_cut_with_the_artboard_tool_record_their_params_as_given() {
fn edit_copy_and_cut_with_the_artboard_tool_replay_on_its_artboards() {
for id in ["edit.copy", "edit.cut"] {
for select in ["select.all", "select.none"] {
let mut s = Session::new();
second_artboard_selected(&mut s);
s.execute(select, &json!({})).unwrap();
s.execute(id, &json!({})).unwrap();
let boards = if id == "edit.cut" { 2 } else { 3 };
assert_eq!(s.doc().unwrap().doc.artboards.len(), boards, "{id} after {select}");
assert_eq!(documents(&replayed(&s)), documents(&s), "{id} after {select}");
let entry = s.journal.last().unwrap().clone();
s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.artboards.len(), boards + 1, "{id} after {select}");
let r = replayed(&s);
assert_ne!(r.tool_id(), "artboard");
assert_eq!(documents(&r), documents(&s), "{id} after {select}");
assert_eq!(entry, (id.to_string(), json!({"selectedArtboards": [1], "art": true, "lockedAndHidden": false})), "{id} after {select}");
}
}
}
/// A Copy after a Paste takes the pasted artboards, which the Artboard tool selected, and so does its
/// replay, though `edit.paste` selects nothing in a new session's tool.
#[test]
fn edit_copy_after_a_paste_with_the_artboard_tool_replays_on_the_pasted_artboards() {
let mut s = Session::new();
second_artboard_selected(&mut s);
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
assert_eq!(s.journal.last().unwrap().1, json!({"selectedArtboards": [3], "art": true, "lockedAndHidden": false}));
s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.artboards.len(), 5);
assert_eq!(documents(&replayed(&s)), documents(&s));
}
/// A recorded action keeps `edit.copy` and `edit.cut` as given, so when it plays they follow the tool
/// chosen then, and a batch step records and leaves out the same values. The locked-art preference
/// that the Artboard tool's copy notes stays in the action.
#[test]
fn edit_copy_and_cut_with_the_artboard_tool_record_nothing_for_an_action() {
for id in ["edit.copy", "edit.cut"] {
let mut s = Session::new();
second_artboard_selected(&mut s);
let start = second_artboard_selected(&mut s);
s.execute(id, &json!({})).unwrap();
assert_eq!(s.journal.last().unwrap(), &(id.to_string(), json!({"lockedAndHidden": false})), "{id}");
let action = s.journal_for_action(start);
assert_eq!(action, [(id.to_string(), json!({"lockedAndHidden": false}))], "{id}");
let mut played = Session::new();
played.execute("file.new", &json!({"width": 400, "height": 200, "created": null})).unwrap();
played.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
played.execute(id, &action[0].1).unwrap();
assert_eq!(played.doc().unwrap().doc.artboards.len(), 1, "{id}");
assert!(played.clipboard.artboards.is_empty(), "{id}");
assert_eq!(played.clipboard.nodes.len(), 1, "{id}");
let mut s = Session::new();
let start = second_artboard_selected(&mut s);
let steps = json!([{"command": id, "params": {}}, {"command": "edit.paste", "params": {}}]);
s.execute("command.batch", &json!({ "commands": steps })).unwrap();
assert_eq!(
s.journal.last().unwrap().1["commands"][0]["params"],
json!({"selectedArtboards": [1], "art": true, "lockedAndHidden": false}),
"{id}"
);
assert_eq!(documents(&replayed(&s)), documents(&s), "{id}");
assert_eq!(s.journal_for_action(start)[0].1["commands"][0]["params"], json!({"lockedAndHidden": false}), "{id}");
}
}
/// Without the Artboard tool, Cut and Copy of objects record nothing more than they did.
#[test]
fn edit_copy_and_cut_of_objects_record_their_params_as_given() {
for id in ["edit.copy", "edit.cut"] {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 200, "created": null})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
s.execute(id, &json!({})).unwrap();
assert_eq!(s.journal.last().unwrap(), &(id.to_string(), json!({})), "{id}");
assert!(s.clipboard.artboards.is_empty(), "{id}");
assert_eq!(documents(&replayed(&s)), documents(&s), "{id}");
}
}
/// A recorded `selectedArtboards` needs only a document and is checked as `indices` is, as are `art`
/// and the rule that a cut leaves one artboard. A failed call changes neither the document nor the
/// clipboard.
#[test]
fn edit_copy_and_cut_read_the_recorded_artboards() {
for id in ["edit.copy", "edit.cut"] {
assert!(matches!(Session::new().execute(id, &json!({"selectedArtboards": [0]})), Err(crate::EngineError::Disabled(..))));
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 200, "created": null})).unwrap();
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 300, "height": 100})).unwrap();
s.execute("shape.rectangle", &json!({"x": 1010, "y": 10, "width": 100, "height": 50})).unwrap();
s.execute("select.none", &json!({})).unwrap();
assert!(matches!(s.execute(id, &json!({})), Err(crate::EngineError::Disabled(..))), "{id}");
let before = documents(&s);
for bad in [
json!({"selectedArtboards": [9]}),
json!({"selectedArtboards": []}),
json!({"selectedArtboards": "1"}),
json!({"selectedArtboards": null}),
json!({"selectedArtboards": [1], "art": "yes"}),
] {
let err = s.execute(id, &bad).unwrap_err().to_string();
assert!(err.contains(id) || err.contains("no such artboard"), "{id} {bad}: {err}");
assert_eq!(documents(&s), before, "{id} {bad}");
assert!(s.clipboard.artboards.is_empty(), "{id} {bad}");
}
s.execute(id, &json!({"selectedArtboards": [1], "art": false})).unwrap();
assert_eq!(s.clipboard.artboards.len(), 1, "{id}");
assert!(s.clipboard.nodes.is_empty(), "{id}");
assert_eq!(s.journal.last().unwrap().1, json!({"selectedArtboards": [1], "art": false}), "{id}");
// Objects selected, `art` is still checked.
s.execute("select.all", &json!({})).unwrap();
let err = s.execute(id, &json!({"art": "yes"})).unwrap_err().to_string();
assert!(err.contains(id), "{id}: {err}");
}
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 200, "created": null})).unwrap();
assert!(s.execute("edit.cut", &json!({"selectedArtboards": [0]})).is_err());
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1);
}
/// `artboard.duplicate` and the cut and copy record the tool's Move/Copy Artwork option: replayed in
/// a new session, where the option is on, the art stays behind as it did.
#[test]
fn artboard_commands_record_the_tools_move_art_option() {
for id in ["artboard.duplicate", "artboard.cut", "artboard.copy"] {
for id in ["artboard.duplicate", "artboard.cut", "artboard.copy", "edit.cut", "edit.copy"] {
let mut s = Session::new();
second_artboard_selected(&mut s);
s.set_tool_option("moveArt", &json!(false));
+42
View File
@@ -164,6 +164,48 @@ fn a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_
}
}
#[test]
fn a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it() {
// The object's own effect, and the fill's (the first appearance item).
for (effect, params, item) in [
("warp.arc", json!({"bend": 40}), json!(null)),
("warp.bulge", json!({"bend": 40, "orientation": "vertical"}), json!(null)),
("warp.inflate", json!({"bend": 40}), json!(0)),
] {
let mut s = session();
let id = gradient_rect(&mut s, Rect::new(40.0, 70.0, 160.0, 130.0), white_black(), false);
// Placed from a quarter of the way across to the right edge.
run(&mut s, "paint.setGradientGeom", json!({"start": [70, 100], "end": [160, 100], "stroke": false}));
let placed = s.doc().unwrap().doc.node(id).and_then(|n| match n.appearance.fill_paint() {
vectorcraft_color::Paint::Gradient(g) => g.geom,
_ => None,
});
assert!(placed.is_some(), "the fill's gradient is placed");
run(&mut s, "effect.apply", json!({"effect": effect, "params": params, "ids": [id.0], "item": item}));
let before = render(&s);
// Fill alone: the gradient becomes strips inside the outline the warp makes.
run(&mut s, "object.expand", json!({"object": false, "stroke": false, "steps": 64}));
let after = render(&s);
let diff = mean_diff(&before, &after);
assert!(diff < 1.0, "{effect} {params} on item {item}: mean difference {diff}");
// The ramp's midpoint, 115 pt before the warp, moves and scales with the warped bounds.
let doc = &s.doc().unwrap().doc;
let b = doc.node(doc.layers[0].id).and_then(|l| l.geometric_bounds()).unwrap();
let want = b.x0 + (115.0 - 40.0) / 120.0 * b.width();
assert!((want - 115.0).abs() > 2.0, "{effect} {params}: the warp moves the midpoint ({b:?})");
let red = |x: usize| f64::from(after.pixels[(100 * 200 + x) * 4]);
// White turns dark once on row 100, and gradually, so the crossing is the ramp's midpoint
// and not an edge of the outline.
let crossings: Vec<usize> =
(b.x0.ceil() as usize + 3..b.x1.floor() as usize - 4).filter(|&x| red(x) >= 127.5 && red(x + 1) < 127.5).collect();
assert_eq!(crossings.len(), 1, "{effect} {params}: white turns dark once on row 100: {crossings:?}");
let x = crossings[0];
assert!(red(x - 2) - red(x + 3) < 100.0, "{effect} {params}: the ramp, not an edge: {} to {}", red(x - 2), red(x + 3));
let half = x as f64 + 0.5 + (red(x) - 127.5) / (red(x) - red(x + 1));
assert!((half - want).abs() < 1.0, "{effect} {params} on item {item}: the midpoint is at {half}, want {want}");
}
}
#[test]
fn eight_strips_match_the_gradient_samples() {
let mut s = session();
+326 -1
View File
@@ -1,6 +1,8 @@
//! Outline Stroke (`object.path.outlineStroke`) and the live Outline Stroke effect produce the
//! stroke as the canvas paints it: dashes, arrowheads, width profiles, alignment, every stroke
//! item in order, with the stroke's opacity and blend mode moved to the new fill.
//! item in order, with the stroke's opacity and blend mode moved to the new fill. Outline Stroke,
//! Expand's Stroke option and Flatten Transparency's Convert All Strokes to Outlines apply the
//! object's geometry effects and each stroke's own to the path before outlining that stroke.
use serde_json::{Value, json};
use vectorcraft_color::BlendMode;
@@ -180,6 +182,329 @@ fn the_live_effect_outlines_the_stroke_it_sits_on() {
assert!(close(g.bounds().unwrap(), Rect::new(0.0, 49.0, 100.0, 51.0), 0.01), "{:?}", g.bounds());
}
fn render(s: &Session) -> vectorcraft_render::Rendered {
let doc = s.doc().unwrap().doc.clone();
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
vectorcraft_render::Renderer::new().render(&doc, 400, 300, vectorcraft_geom::Affine::IDENTITY, &opts)
}
/// Pixels whose color differs by more than `tol` in any channel.
fn differing(a: &vectorcraft_render::Rendered, b: &vectorcraft_render::Rendered, tol: u8) -> usize {
a.pixels
.as_chunks::<4>()
.0
.iter()
.zip(b.pixels.as_chunks::<4>().0)
.filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > tol))
.count()
}
/// Does `n` or anything in it have a geometry effect, its own or a fill's or stroke's?
fn has_geometry_effects(n: &Node) -> bool {
let geometry = |e: &vectorcraft_doc::Effect| vectorcraft_render::effects::is_geometry(&e.id);
let mut found = false;
n.walk(&mut |m| found |= m.appearance.effects.iter().any(geometry) || m.appearance.items.iter().any(|i| i.effects().iter().any(geometry)));
found
}
/// A selected 100 pt square at (150, 100) with a 4 pt black stroke and fill `fill` (paint.setFill's),
/// and `effects` (effect.apply's `effect` and `params`) on appearance item `item` (`null`: on the
/// object).
fn square_with(s: &mut Session, fill: Value, item: Value, effects: &[(&str, Value)]) {
s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap();
s.execute("paint.setFill", &fill).unwrap();
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
s.execute("stroke.set", &json!({"weight": 4})).unwrap();
for (effect, params) in effects {
s.execute("effect.apply", &json!({"effect": effect, "params": params, "item": item})).unwrap();
}
}
/// The geometry effects the tests put on the stroke or on the object.
fn geometry_effects() -> [(&'static str, Value); 2] {
[("path.offsetPath", json!({"offset": 10})), ("distort.zigZag", json!({"size": 6, "ridges": 3}))]
}
/// The commands that outline strokes: Outline Stroke, Expand with Stroke alone, and Flatten
/// Transparency with Convert All Strokes to Outlines.
fn outlining() -> [(&'static str, Value); 3] {
[
("object.path.outlineStroke", json!({})),
("object.expand", json!({"object": false, "fill": false, "stroke": true})),
("object.flattenTransparency", json!({"strokesToOutlines": true})),
]
}
/// What `command` with `params` leaves in place of the selected object.
fn outlined_by(s: &mut Session, command: &str, params: &Value) -> Node {
let r = s.execute(command, params).unwrap_or_else(|e| panic!("{command}: {e}"));
let id = NodeId(r["ids"][0].as_u64().unwrap());
s.doc().unwrap().doc.node(id).cloned().unwrap()
}
/// For each command in [`outlining`] and each of [`geometry_effects`] on `item` of a square filled
/// with `fill`: the pixels that differ by more than 64 from the canvas before, and whether a
/// geometry effect is left on the result.
fn outlined_as_drawn(fill: Value, item: Value) -> Vec<(&'static str, &'static str, usize, bool)> {
let mut results = vec![];
for (command, p) in outlining() {
for (effect, params) in geometry_effects() {
let mut s = session();
square_with(&mut s, fill.clone(), item.clone(), &[(effect, params)]);
let before = render(&s);
let n = outlined_by(&mut s, command, &p);
results.push((command, effect, differing(&before, &render(&s), 64), has_geometry_effects(&n)));
}
}
results
}
/// What [`outlined_as_drawn`] gives when every outline looks as the canvas drew the object.
fn as_drawn() -> Vec<(&'static str, &'static str, usize, bool)> {
outlining().iter().flat_map(|(command, _)| geometry_effects().map(|(effect, _)| (*command, effect, 0, false))).collect()
}
/// A stroke's own geometry effects reshape the path it strokes, as on the canvas: the outline is
/// the stroke of the offset or zig-zagged path, not the stroke's outline offset or zig-zagged.
#[test]
fn a_geometry_effect_on_the_stroke_shapes_the_path_it_outlines() {
assert_eq!(outlined_as_drawn(json!({"none": true}), json!(1)), as_drawn());
// The stroke of the square offset by 10: 4 pt wide around a 120 pt square.
let mut s = session();
square_with(&mut s, json!({"none": true}), json!(1), &[("path.offsetPath", json!({"offset": 10}))]);
let n = outline(&mut s);
assert!(close(n.geometric_bounds().unwrap(), Rect::new(138.0, 88.0, 262.0, 212.0), 0.01), "{:?}", n.geometric_bounds());
assert!(near(area(&n), 124.0 * 124.0 - 116.0 * 116.0, 2.0), "{}", area(&n));
}
/// A stroke whose geometry effect leaves no path paints nothing on the canvas, and outlining it
/// replaces the live stroke with an empty group.
#[test]
fn a_stroke_its_effects_empty_is_outlined_to_nothing() {
for (command, p) in outlining() {
let mut s = session();
square_with(&mut s, json!({"none": true}), json!(1), &[("path.offsetPath", json!({"offset": -60}))]);
let before = render(&s);
let n = outlined_by(&mut s, command, &p);
assert!(matches!(&n.kind, NodeKind::Group { children, .. } if children.is_empty()), "{command}: {n:?}");
let mut stroked = false;
for layer in &s.doc().unwrap().doc.layers {
layer.walk(&mut |m| stroked |= m.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_))));
}
assert!(!stroked, "{command} left a live stroke");
assert_eq!(differing(&before, &render(&s), 64), 0, "{command}");
}
}
/// A drop shadow on a stroke with a geometry effect stays on the outline, which draws as the
/// stroke did.
#[test]
fn the_outline_of_a_reshaped_stroke_keeps_its_drop_shadow() {
let mut s = session();
square_with(&mut s, json!({"none": true}), json!(1), &[("path.offsetPath", json!({"offset": 10})), ("stylize.dropShadow", json!({}))]);
let before = render(&s);
let n = outline(&mut s);
let kept: Vec<&str> = n.appearance.items.iter().flat_map(|i| i.effects()).map(|e| e.id.as_str()).collect();
assert_eq!(kept, ["stylize.dropShadow"]);
assert_eq!(differing(&before, &render(&s), 64), 0, "the shadowed outline draws as the stroke did");
}
/// The brush art of a brushed stroke with a geometry effect follows the path the effect makes.
#[test]
fn a_brush_draws_along_the_path_the_stroke_effects_make() {
let mut s = session();
square_with(&mut s, json!({"none": true}), json!(1), &[("path.offsetPath", json!({"offset": 10}))]);
s.execute("brush.apply", &json!({"name": "Tapered Stroke"})).unwrap();
let id = s.doc().unwrap().selection.objects[0];
let st = s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().cloned().unwrap();
assert!(st.brush.is_some() && st.effects.len() == 1, "{st:?}");
let before = render(&s);
let n = outline(&mut s);
assert!(!vectorcraft_brush::has_brush(&n) && !has_geometry_effects(&n), "brush art: {n:?}");
assert_eq!(differing(&before, &render(&s), 64), 0, "the brush art draws as the brushed stroke did");
}
/// The object's geometry effects reshape the path its fills paint and its strokes outline, as on
/// the canvas, and do not stay on the result.
#[test]
fn a_geometry_effect_on_the_object_shapes_the_path_it_outlines() {
assert_eq!(outlined_as_drawn(json!({"color": "#33aa55"}), Value::Null), as_drawn());
}
/// The canvas paints a gradient fill or stroke on each copy that the object's Transform effect
/// makes by itself; the outlined result draws the same, with the gradient fitted to each copy.
#[test]
fn each_transform_copy_keeps_its_own_gradient() {
let mut differ = vec![];
for on_stroke in [false, true] {
for (command, p) in outlining() {
let mut s = session();
let fill = if on_stroke { json!({"none": true}) } else { json!({"gradient": {"kind": "linear"}}) };
square_with(&mut s, fill, Value::Null, &[("distort.transform", json!({"moveH": 120, "copies": 1}))]);
if on_stroke {
s.execute("paint.setStroke", &json!({"gradient": {"kind": "linear"}})).unwrap();
s.execute("stroke.set", &json!({"weight": 12})).unwrap();
}
let before = render(&s);
outlined_by(&mut s, command, &p);
differ.push((on_stroke, command, differing(&before, &render(&s), 64)));
}
}
let none_differ: Vec<_> = [false, true].into_iter().flat_map(|on_stroke| outlining().map(|(command, _)| (on_stroke, command, 0))).collect();
assert_eq!(differ, none_differ);
}
/// With Scale Strokes & Effects on, the canvas draws the stroke of a solid square that its
/// Transform effect scales to 200% at 8 pt; the outlined result draws the same.
#[test]
fn a_transform_that_scales_strokes_outlines_the_scaled_stroke() {
let scale = json!({"scaleH": 200, "scaleV": 200, "strokes": true});
let mut differ = vec![];
for (command, p) in outlining() {
let mut s = session();
square_with(&mut s, json!({"color": "#33aa55"}), Value::Null, &[("distort.transform", scale.clone())]);
let before = render(&s);
outlined_by(&mut s, command, &p);
differ.push((command, differing(&before, &render(&s), 64)));
}
let none_differ: Vec<_> = outlining().map(|(command, _)| (command, 0)).into_iter().collect();
assert_eq!(differ, none_differ);
}
/// Random in a Transform effect takes its seed from the object's id. The outline draws as the
/// canvas drew the object with Random in a Transform effect that copies a gradient square, in a
/// Transform effect on the stroke of such a square, and in a Transform effect on the fill of a
/// plain square.
#[test]
fn outlines_keep_the_random_transforms_the_canvas_drew() {
let random = json!({"moveH": 30, "moveV": 30, "rotate": 40, "random": true});
let cases = [
("random copies", json!({"gradient": {"kind": "linear"}}), json!({"moveH": 120, "rotate": 30, "copies": 1, "random": true}), None),
("copies, random stroke", json!({"gradient": {"kind": "linear"}}), json!({"moveH": 120, "copies": 1}), Some(1)),
("random fill", json!({"color": "#33aa55"}), json!({}), Some(0)),
];
let mut differ = vec![];
for (case, fill, object, item) in &cases {
for (command, p) in outlining() {
let mut s = session();
square_with(&mut s, fill.clone(), Value::Null, &[]);
if object != &json!({}) {
s.execute("effect.apply", &json!({"effect": "distort.transform", "params": object})).unwrap();
}
s.execute("stroke.set", &json!({"weight": 12})).unwrap();
if let Some(item) = item {
s.execute("effect.apply", &json!({"effect": "distort.transform", "params": random, "item": item})).unwrap();
}
let before = render(&s);
outlined_by(&mut s, command, &p);
differ.push((*case, command, differing(&before, &render(&s), 64)));
}
}
let none_differ: Vec<_> = cases.iter().flat_map(|(case, ..)| outlining().map(|(command, _)| (*case, command, 0))).collect();
assert_eq!(differ, none_differ);
}
/// For each command in [`outlining`] on a stroke-only square with `effects` on appearance item
/// `item`: the pixels that differ by more than 64 from the canvas before, whether a geometry
/// effect is left on the result, and (for Outline Stroke) the outline's box and area.
fn outlined_square(item: Value, effects: &[(&str, Value)], hide: bool) -> (Vec<(&'static str, usize, bool)>, Rect, f64) {
let mut results = vec![];
let mut shape = (Rect::ZERO, 0.0);
for (command, p) in outlining() {
let mut s = session();
square_with(&mut s, json!({"none": true}), item.clone(), effects);
if hide {
for index in 0..effects.len() {
s.execute("effect.setParams", &json!({"index": index, "visible": false, "item": item})).unwrap();
}
}
let before = render(&s);
let n = outlined_by(&mut s, command, &p);
if command == "object.path.outlineStroke" {
shape = (n.geometric_bounds().unwrap(), area(&n));
}
results.push((command, differing(&before, &render(&s), 64), has_geometry_effects(&n)));
}
(results, shape.0, shape.1)
}
/// What [`outlined_square`] gives for each command when the outline draws as the canvas did and
/// keeps no geometry effect.
fn drawn_plain() -> Vec<(&'static str, usize, bool)> {
outlining().map(|(command, _)| (command, 0, false)).into_iter().collect()
}
/// A path with no effects outlines its own stroke: 4 pt wide around the 100 pt square.
#[test]
fn a_path_without_effects_outlines_its_own_stroke() {
let (results, bounds, a) = outlined_square(Value::Null, &[], false);
assert_eq!(results, drawn_plain());
assert!(close(bounds, Rect::new(148.0, 98.0, 252.0, 202.0), 0.01), "{bounds:?}");
assert!(near(a, 104.0 * 104.0 - 96.0 * 96.0, 0.5), "{a}");
}
/// Round Corners on the object rounds the path whose stroke is outlined, with the radius in
/// points as the canvas draws it: corners of radius 12 outside and 8 inside.
#[test]
fn round_corners_on_the_object_round_the_stroke_it_outlines() {
let (results, bounds, a) = outlined_square(Value::Null, &[("stylize.roundCorners", json!({"radius": 10}))], false);
assert_eq!(results, drawn_plain());
assert!(close(bounds, Rect::new(148.0, 98.0, 252.0, 202.0), 0.01), "{bounds:?}");
let rounded = 104.0 * 104.0 - 96.0 * 96.0 - (4.0 - std::f64::consts::PI) * (12.0 * 12.0 - 8.0 * 8.0);
assert!(near(a, rounded, 2.0), "{a}, rounded {rounded}");
}
/// A hidden geometry effect, on the object or on the stroke, does not draw on the canvas and does
/// not shape the outline.
#[test]
fn a_hidden_geometry_effect_does_not_shape_the_outline() {
for item in [Value::Null, json!(1)] {
let (results, bounds, a) = outlined_square(item.clone(), &geometry_effects(), true);
let drawn: Vec<_> = results.iter().map(|(command, differ, _)| (*command, *differ)).collect();
assert_eq!(drawn, drawn_plain().into_iter().map(|(command, differ, _)| (command, differ)).collect::<Vec<_>>(), "item {item}");
assert!(close(bounds, Rect::new(148.0, 98.0, 252.0, 202.0), 0.01), "item {item}: {bounds:?}");
assert!(near(a, 104.0 * 104.0 - 96.0 * 96.0, 0.5), "item {item}: {a}");
}
}
/// An effect on the object that is not a geometry effect, such as a Gaussian Blur, stays on the
/// result of Outline Stroke and Expand while the geometry effect shapes the path. Flatten
/// Transparency renders raster effects into an image instead.
#[test]
fn an_effect_that_is_not_geometry_stays_on_the_result() {
for (command, p) in outlining().into_iter().filter(|(command, _)| *command != "object.flattenTransparency") {
let mut s = session();
square_with(
&mut s,
json!({"color": "#33aa55"}),
Value::Null,
&[("distort.zigZag", json!({"size": 6, "ridges": 3})), ("blur.gaussian", json!({}))],
);
let n = outlined_by(&mut s, command, &p);
let kept: Vec<&str> = n.appearance.effects.iter().map(|e| e.id.as_str()).collect();
assert_eq!(kept, ["blur.gaussian"], "{command}");
assert!(!has_geometry_effects(&n), "{command}: {n:?}");
}
}
/// Undo after outlining a path with geometry effects on the object and on its stroke gives back
/// the path as it was, effects included, and its selection.
#[test]
fn undo_gives_back_the_path_with_its_effects() {
for (command, p) in outlining() {
let mut s = session();
square_with(&mut s, json!({"color": "#33aa55"}), Value::Null, &[("distort.zigZag", json!({"size": 6, "ridges": 3}))]);
s.execute("effect.apply", &json!({"effect": "path.offsetPath", "params": {"offset": 10}, "item": 1})).unwrap();
let before = s.doc().unwrap().doc.layers.clone();
let selected = s.doc().unwrap().selection.objects.clone();
outlined_by(&mut s, command, &p);
assert_ne!(s.doc().unwrap().doc.layers, before, "{command} changed nothing");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.layers, before, "{command}");
assert_eq!(s.doc().unwrap().selection.objects, selected, "{command}");
}
}
#[test]
fn brushed_strokes_outline_to_their_brush_art() {
let mut s = session();
+104 -1
View File
@@ -64,7 +64,7 @@ fn an_unknown_key_is_named_with_the_nearest_accepted_one() {
("file.new", json!(null)),
// Keys listed by reference, and any key where the description says so.
("file.newPresets.save", json!({"name": "a", "width": 300})),
("document.export", json!({"format": "png", "anyFormatOption": 1})),
("document.export", json!({"format": "png", "styling": "style"})),
// Not a command: running it says so.
("no.such", json!({"x": 1})),
] {
@@ -74,6 +74,109 @@ fn an_unknown_key_is_named_with_the_nearest_accepted_one() {
assert!(check_keys("x", "{a, …as file.new}", &json!({"b": 1})).is_err());
}
/// An export's options: a key no format reads is refused, at the top level and in the PDF objects,
/// under each command that takes them.
#[test]
fn export_options_refuse_a_key_no_format_reads() {
for id in ["document.export", "document.exportSelection", "document.serialize"] {
assert_eq!(
refusal(id, json!({"format": "pdf", "standard": "pdfX4", "noSuchOption": true})),
"unknown parameter `noSuchOption`; no format takes it (see document.formats)"
);
assert_eq!(refusal(id, json!({"format": "pdf", "standardd": "pdfX4"})), "unknown parameter `standardd`; did you mean `standard`?");
assert_eq!(
refusal(id, json!({"format": "pdf", "options": {"standard": "pdfX4"}})),
"unknown parameter `options`; no format takes it (see document.formats)"
);
}
// A command that writes one format refuses another format's option, and says which format.
for (id, why) in [
("document.exportPdf", "PDF doesn't take it"),
("document.pdfSettings", "PDF doesn't take it"),
("pdf.preset.save", "PDF doesn't take it"),
("document.exportEps", "EPS doesn't take it"),
("document.exportDxf", "DXF doesn't take it"),
] {
for key in ["noSuchOption", "quality"] {
assert_eq!(refusal(id, json!({key: 1})), format!("unknown parameter `{key}`; {why} (see document.formats)"));
}
}
assert_eq!(
refusal("document.exportEps", json!({"flattener": {"balanse": 50}})),
"unknown parameter `flattener.balanse`; did you mean `flattener.balance`?"
);
assert!(refusal("document.exportDxf", json!({"flattener": {}})).contains("DXF doesn't take it"));
assert!(refusal("document.exportEps", json!({"marks": {}})).contains("EPS doesn't take it"));
for id in ["document.pdfSettings", "pdf.preset.save"] {
assert!(refusal(id, json!({"marks": {"useDocumentBleed": true}})).starts_with("unknown parameter `marks.useDocumentBleed`;"), "{id}");
}
for (given, message) in [
(json!({"marks": {"useDocumentBleed": true}}), "unknown parameter `marks.useDocumentBleed`; `marks` takes:"),
(json!({"bleed": {"useDocumen": true}}), "unknown parameter `bleed.useDocumen`; did you mean `bleed.useDocument`?"),
(json!({"output": {"intent": "x"}}), "unknown parameter `output.intent`;"),
(json!({"advanced": {"outlineTxt": true}}), "unknown parameter `advanced.outlineTxt`; did you mean `advanced.outlineText`?"),
(json!({"security": {"openPasswrd": "x"}}), "unknown parameter `security.openPasswrd`; did you mean `security.openPassword`?"),
(json!({"compression": {"color": {"dpi": 300}}}), "unknown parameter `compression.color.dpi`;"),
(json!({"flattener": {"balanse": 50}}), "unknown parameter `flattener.balanse`; did you mean `flattener.balance`?"),
] {
for id in ["document.export", "document.exportPdf"] {
let got = refusal(id, given.clone());
assert!(got.starts_with(message), "{id} {given}: {got}");
}
}
}
/// The check passes each key `document.formats` lists, each key a command's own description names,
/// and each field of the PDF settings and the flattener options. A key an encoder reads that none
/// of these names is outside this test.
#[test]
fn export_options_pass_every_listed_key() {
use crate::cmd::fileio::{FORMATS, dxf, eps, pdf::OPTIONS};
for f in FORMATS.iter().filter(|f| f.write) {
for o in f.options {
let params = json!({"format": f.id, o.name: null});
assert!(check_params("document.export", &params).is_ok(), "{params}");
}
}
for (id, options) in [("document.exportEps", eps::OPTIONS), ("document.exportDxf", dxf::OPTIONS), ("document.exportPdf", OPTIONS)] {
for o in options {
assert!(check_params(id, &json!({o.name: null})).is_ok(), "{id} {}", o.name);
}
}
for id in [
"document.export",
"document.exportSelection",
"document.serialize",
"document.exportPdf",
"document.pdfSettings",
"pdf.preset.save",
"document.exportEps",
"document.exportDxf",
] {
// An export reads these two when it writes over the file a lossy document came from. The
// PDF settings and preset commands take them through document.exportPdf's description.
check_params(id, &json!({"path": "a", "acknowledgeLoss": true})).unwrap_or_else(|e| panic!("{id}: {e}"));
let keys = doc_keys(find_command(id).unwrap().params).unwrap().keys;
let refused: Vec<&str> = keys.into_iter().filter(|key| check_params(id, &json!({*key: null})).is_err()).collect();
// `document.exportPdf`'s description explains the page boxes as `BleedBox = …` between its
// keys, which reads as two more keys.
let prose = if id == "document.exportPdf" { vec!["BleedBox", "MediaBox"] } else { vec![] };
assert_eq!(refused, prose, "{id} names and refuses these");
}
let settings = serde_json::to_value(vectorcraft_pdf::PdfSettings::default()).unwrap();
for key in settings.as_object().unwrap().keys() {
assert!(OPTIONS.iter().any(|o| o.name == key), "document.formats doesn't list the PDF option `{key}`");
}
let mut every = settings;
every["flattener"] = serde_json::to_value(crate::cmd::flatten::FlattenOptions::default()).unwrap();
every["security"]["openPassword"] = json!("a");
every["security"]["permissionsPassword"] = json!("b");
every["preset"] = json!("High Quality Print");
for id in ["document.export", "document.exportPdf", "document.pdfSettings", "pdf.preset.save"] {
check_params(id, &every).unwrap_or_else(|e| panic!("{id}: {e}"));
}
}
/// `stroke.set` names every key it reads (its own, `item`, and `ids`/`id`/`target`), so a
/// misnamed one is refused instead of answering `objects: 1` with nothing changed.
#[test]
+229 -12
View File
@@ -1277,6 +1277,225 @@ fn a_cancelled_filter_run_changes_nothing() {
assert_eq!(s.doc().unwrap().doc, before);
}
/// The manifest of a filter `id` named "Cmd Test" of `kind` with `commands` (a JSON list).
fn with_commands(id: &str, kind: &str, commands: &str) -> String {
format!(r#"{{"id":"{id}","name":"Cmd Test","version":"1","kind":"{kind}","commands":{commands}}}"#)
}
/// Two commands: Grow, with a parameter, and Tidy, whose `menu` names another menu.
const GROW_AND_TIDY: &str = r#"[{"id":"grow","label":"Grow","params":{"by":{"type":"number","min":0,"max":10,"default":2}}},
{"id":"tidy","label":"Tidy","menu":"File"}]"#;
/// A plug-in that adds a triangle when it runs as its command `grow` (`_context.command`) and
/// returns its input unchanged otherwise.
fn grower(id: &str) -> String {
let triangle = r#"{"objects":[{"path":{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[5,0]},{"p":[5,5]}]}]}}]}"#;
tpl::when_given(&with_commands(id, "filter", GROW_AND_TIDY), tpl::Given::Params, r#""command":"grow""#, triangle)
}
/// The command listing's entry for `id`.
fn listed(s: &Session, id: &str) -> Option<crate::cmd::CommandInfo> {
s.commands().into_iter().find(|c| c.id == id)
}
fn objects(s: &Session) -> usize {
s.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum()
}
/// A plug-in's commands are commands of their own while it is installed: listed under Object ›
/// Plug-ins › its name whatever menu the manifest names, run with their checked parameters and
/// `_context.command` as one undo step named after them, and gone once it is removed or replaced
/// by a module without them.
#[test]
fn plugin_commands_are_registered_run_and_removed() {
let mut s = session();
let id = "test.engine.cmds";
let (grow, tidy) = ("plugin.test.engine.cmds.grow", "plugin.test.engine.cmds.tidy");
let info = install_wat(&mut s, &grower(id));
assert_eq!(info["commands"][0]["command"], grow);
assert_eq!(info["commands"][1]["label"], "Tidy");
for (cmd, label) in [(grow, "Grow"), (tidy, "Tidy")] {
let c = listed(&s, cmd).unwrap_or_else(|| panic!("{cmd} is listed"));
assert_eq!((c.label, c.menu.as_slice()), (label, &["Object", "Plug-ins", "Cmd Test"][..]), "{cmd}");
assert!(c.enabled, "{cmd}");
}
assert!(crate::cmd::find_command(grow).is_some());
run(&mut s, "select.none", json!({}));
let (before, undo) = (objects(&s), s.doc().unwrap().history.undo.len());
let r = run(&mut s, grow, json!({"by": 3}));
assert_eq!((r["plugin"].as_str(), r["command"].as_str()), (Some(id), Some("grow")));
assert_eq!(objects(&s), before + 1, "the command added its triangle");
let st = s.doc().unwrap();
assert_eq!((st.history.undo.len(), st.history.undo.last().unwrap().label.as_str()), (undo + 1, "Grow"));
run(&mut s, "edit.undo", json!({}));
assert_eq!(objects(&s), before);
// Its parameters are checked before it runs.
let e = s.execute(grow, &json!({"by": "lots"})).unwrap_err();
assert!(matches!(&e, EngineError::BadParams { cmd, .. } if cmd == grow), "{e}");
assert_eq!(objects(&s), before);
// Another command of the same plug-in is told apart by its id.
run(&mut s, tidy, json!({}));
assert_eq!(objects(&s), before, "tidy returns its input unchanged");
// Replaced by a module without commands, its commands are gone; installed again, back.
install_wat(&mut s, &tpl::module(&tpl::manifest(id, "filter", "{}"), tpl::ECHO, "", 1));
assert!(listed(&s, grow).is_none() && crate::cmd::find_command(grow).is_none());
install_wat(&mut s, &grower(id));
assert!(listed(&s, grow).is_some());
run(&mut s, "plugin.remove", json!({"id": id}));
assert!(listed(&s, grow).is_none() && listed(&s, tidy).is_none());
assert!(s.execute(grow, &json!({})).is_err(), "removed, it no longer runs");
}
/// A command run is journaled like any other, so an action records it with its parameters and
/// plays it back.
#[test]
fn actions_record_and_play_plugin_commands() {
let mut s = session();
let id = "test.engine.cmds.actions";
let grow = crate::cmd::plugin::command_id(id, "grow");
install_wat(&mut s, &grower(id));
run(&mut s, "select.none", json!({}));
let before = objects(&s);
let start = s.journal.len();
run(&mut s, &grow, json!({"by": 4}));
let steps = s.journal_for_action(start);
assert_eq!(steps, [(grow.clone(), json!({"by": 4}))]);
run(&mut s, "edit.undo", json!({}));
let commands: Vec<Value> = steps.iter().map(|(c, p)| json!({"command": c, "params": p})).collect();
// The triangle a run adds is selected, and the next run would be given it: deselect between.
let none = json!({"command": "select.none", "params": {}});
run(&mut s, "command.batch", json!({"label": "Grow Twice", "commands": [commands[0].clone(), none, commands[0].clone()]}));
assert_eq!(objects(&s), before + 2, "both steps ran");
run(&mut s, "plugin.remove", json!({"id": id}));
}
/// A command that traps leaves the document and its history as they were, and the error names
/// the plug-in and the command.
#[test]
fn a_failing_plugin_command_changes_nothing_and_names_itself() {
let mut s = session();
rect(&mut s, 10.0, "#ff0000");
run(&mut s, "select.all", json!({}));
let id = "test.engine.cmds.trap";
install_wat(&mut s, &tpl::module(&with_commands(id, "filter", r#"[{"id":"boom","label":"Boom"}]"#), "unreachable", "", 1));
let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
let e = s.execute(&crate::cmd::plugin::command_id(id, "boom"), &json!({})).unwrap_err().to_string();
assert!(e.contains("“Cmd Test” › Boom: ") && e.contains("trapped"), "{e}");
assert_eq!(s.doc().unwrap().doc, before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
run(&mut s, "plugin.remove", json!({"id": id}));
}
/// A command that runs out of its instruction budget leaves the document and its history as they
/// were, and the error names the plug-in and the command.
#[test]
fn a_plugin_command_out_of_budget_changes_nothing_and_names_itself() {
let mut s = session();
rect(&mut s, 10.0, "#ff0000");
run(&mut s, "select.all", json!({}));
let id = "test.engine.cmds.spin";
let triangle = r#"{"objects":[{"path":{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[5,0]},{"p":[5,5]}]}]}}]}"#;
// More steps than a light budget pays for (as the plug-in crate's budget test counts them).
install_wat(&mut s, &tpl::spinning(&with_commands(id, "filter", r#"[{"id":"spin","label":"Spin"}]"#), 100_000_000, triangle));
let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
let e = s.execute(&crate::cmd::plugin::command_id(id, "spin"), &json!({})).unwrap_err().to_string();
assert!(e.contains("“Cmd Test” › Spin: ") && e.contains("instruction budget"), "{e}");
assert_eq!(s.doc().unwrap().doc, before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
run(&mut s, "plugin.remove", json!({"id": id}));
}
/// An `ids` that is not a list, or that holds an entry that is not an object id, is an error naming
/// `ids` for a plug-in command and for `plugin.run`, and is never read as the selection; left out
/// or null, `ids` is the selection.
#[test]
fn a_malformed_ids_is_refused_not_read_as_the_selection() {
let mut s = session();
let id = "test.engine.cmds.ids";
let grow = crate::cmd::plugin::command_id(id, "grow");
install_wat(&mut s, &grower(id));
rect(&mut s, 10.0, "#ff0000");
run(&mut s, "select.all", json!({}));
let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
let cases = [
(grow.as_str(), json!(5)),
(grow.as_str(), json!([1, "x"])),
(grow.as_str(), json!([-1])),
("plugin.run", json!("all")),
("plugin.run", json!([1.5])),
];
for (cmd, ids) in cases {
let mut p = json!({"ids": ids});
if cmd == "plugin.run" {
p["id"] = json!(id);
}
let e = s.execute(cmd, &p).unwrap_err();
assert!(matches!(&e, EngineError::BadParams { cmd: c, .. } if c == cmd), "{cmd} {ids}: {e}");
let said = e.to_string();
assert!(
said.contains("ids must be an array of non-negative integer object ids") || said.contains("not a non-negative integer object id"),
"{cmd} {ids}: {e}"
);
assert_eq!(s.doc().unwrap().doc, before, "{cmd} {ids}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{cmd} {ids}");
}
// Left out or null, the command runs on the selection.
for p in [json!({}), json!({"ids": null})] {
let r = run(&mut s, &grow, p.clone());
assert_eq!(r["command"], "grow", "{p}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{p}");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc().unwrap().doc, before, "{p}");
}
run(&mut s, "plugin.remove", json!({"id": id}));
}
/// A live effect's commands are listed under Effect › Plug-ins and run under its permissions (with
/// commands it gets `doc.write` unless its list leaves it out), and labels show bidirectional
/// characters escaped. A malformed
/// `commands` entry is refused at install, and the error names it.
#[test]
fn effect_commands_labels_and_malformed_entries() {
let mut s = session();
let id = "test.engine.cmds.effect";
let triangle = r#"{"objects":[{"path":{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[5,0]},{"p":[5,5]}]}]}}]}"#;
install_wat(&mut s, &tpl::returning(&with_commands(id, "effect", "[{\"id\":\"flip\",\"label\":\"Flip\u{202E}txt\"}]"), triangle));
let c = listed(&s, &crate::cmd::plugin::command_id(id, "flip")).expect("listed");
assert_eq!((c.label, c.menu.as_slice()), ("Flip\\u{202E}txt", &["Effect", "Plug-ins", "Cmd Test"][..]));
run(&mut s, "select.none", json!({}));
// Without `permissions`, a live effect with commands may change the document: its command adds
// the triangle, as one undo step.
let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
let out = run(&mut s, &crate::cmd::plugin::command_id(id, "flip"), json!({}));
assert_eq!(out["ids"].as_array().map(Vec::len), Some(1), "{out}");
assert_ne!(s.doc().unwrap().doc, before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
run(&mut s, "plugin.remove", json!({"id": id}));
// One that lists its permissions without `doc.write` is refused the change, and the document
// stays as it was.
let listed = format!(
r#"{{"id":"{id}","name":"Cmd Test","version":"1","kind":"effect","permissions":["doc.read","effect.render"],"commands":[{{"id":"flip","label":"Flip"}}]}}"#
);
let wasm = wat::parse_str(tpl::returning(&listed, triangle)).unwrap();
let hash = vectorcraft_plugins::permissions::sha256_hex(&wasm);
let grant = json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read", "effect.render"], "hash": hash});
run(&mut s, "plugin.install", grant);
let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
let e = s.execute(&crate::cmd::plugin::command_id(id, "flip"), &json!({})).unwrap_err().to_string();
assert!(e.contains("wasn't granted `doc.write`"), "{e}");
assert_eq!(s.doc().unwrap().doc, before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
run(&mut s, "plugin.remove", json!({"id": id}));
let bad = id.replace("effect", "bad");
let wasm = wat::parse_str(tpl::module(&with_commands(&bad, "filter", r#"[{"id":"ok","label":"A"},{"id":"Bad","label":"B"}]"#), tpl::ECHO, "", 1));
let e = s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm.unwrap())})).unwrap_err().to_string();
assert!(e.contains("command `Bad`"), "{e}");
assert!(vectorcraft_plugins::registry::get(&bad).is_none());
}
/// A filter `id` of API level 2 that lists `settings` and `cache`, whose `vc_run` runs `first`
/// (instructions that trap unless each storage call returns what it should), with the keys "zoom"
/// at 0x2000 and "tile" at 0x2004 and the value "hello" at 0x2100.
@@ -1308,10 +1527,11 @@ fn replace_storing(s: &mut Session, id: &str, first: &str) -> Value {
/// A plug-in's settings and cache are kept in the plug-in store under its id: the next session
/// has them, replacing the plug-in keeps them, another plug-in can't read them, `plugin.clearCache`
/// empties the cache alone and `plugin.remove` deletes both. The caps and the bytes each call is
/// charged follow ([`plugin_storage_is_capped_and_survives_a_damaged_settings_file`],
/// [`plugin_storage_calls_are_charged_the_files_they_read_and_write`]) in the same test, since
/// clearing every plug-in's cache would empty theirs.
/// with its id empties its cache alone and `plugin.remove` deletes both. The caps and the bytes
/// each call is charged follow ([`plugin_storage_is_capped_and_survives_a_damaged_settings_file`],
/// [`plugin_storage_calls_are_charged_the_files_they_read_and_write`]) in the same test.
/// `plugin.clearCache` without an id empties the cache of every plug-in installed in the process,
/// so it is tested in a binary of its own (`tests/plugin_clear_cache.rs`).
#[test]
fn plugins_keep_settings_and_a_cache_of_their_own_in_the_store() {
let folder = crate::tests_links::Folder::new("plugin-storage");
@@ -1338,16 +1558,13 @@ fn plugins_keep_settings_and_a_cache_of_their_own_in_the_store() {
install_storing(&mut next, other, READS);
assert!(next.execute("plugin.run", &json!({"id": other, "ids": []})).is_err());
// Clearing the cache leaves the settings.
assert_eq!(run(&mut next, "plugin.clearCache", json!({"id": id})), json!({"cleared": [id], "bytesFreed": 3}));
assert_eq!(run(&mut next, "plugin.info", json!({"id": id}))["cacheBytes"], json!(0));
run(&mut next, "plugin.run", json!({"id": id, "ids": []}));
// Clearing the cache leaves the settings, and the other plug-in's cache.
replace_storing(&mut next, other, WRITES);
run(&mut next, "plugin.run", json!({"id": other, "ids": []}));
let all = run(&mut next, "plugin.clearCache", json!({}));
let cleared = all["cleared"].as_array().unwrap();
assert!(cleared.contains(&json!(id)) && cleared.contains(&json!(other)) && all["bytesFreed"] == json!(3), "{all}");
assert_eq!(run(&mut next, "plugin.info", json!({"id": other}))["cacheBytes"], json!(0));
assert_eq!(run(&mut next, "plugin.clearCache", json!({"id": id})), json!({"cleared": [id], "bytesFreed": 3}));
assert_eq!(run(&mut next, "plugin.info", json!({"id": id}))["cacheBytes"], json!(0));
assert_eq!(run(&mut next, "plugin.info", json!({"id": other}))["cacheBytes"], json!(3));
run(&mut next, "plugin.run", json!({"id": id, "ids": []}));
assert!(next.execute("plugin.clearCache", &json!({"id": "test.engine.storing.missing"})).is_err());
run(&mut next, "plugin.remove", json!({"id": id}));
+50 -5
View File
@@ -4034,6 +4034,12 @@ proptest! {
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat();
survive("mutated .ai symbols", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?;
survive("mutated .ai symbols, trusted", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?;
// A symbol library: the same symbols and list, no layers.
let library = mutate_text(&symbol_library_text(), cut, &edits);
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&library)].concat();
survive("mutated .ai symbol library", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?;
let eps = vectorcraft_testkit::ai::eps(library.as_bytes(), vectorcraft_testkit::ai::page_ps());
survive("mutated symbol library in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?;
}
/// A `.ai` whose art styles, as the writer writes them, are damaged: the styles, the style
@@ -4052,13 +4058,13 @@ proptest! {
const STYLES_FUZZ_CHARS: usize = 64_000;
/// The editing data VectorCraft writes for a document with a drop shadow on a path, a gradient fill
/// and a width profile on others, a stroked even-odd path with an opacity mask and Pathfinder › Add
/// on a group.
/// and a width profile on others, a stroked even-odd path with an opacity mask, Pathfinder › Add on
/// a group and a graphic style of its own with effects on one of its fills.
fn styles_text() -> &'static str {
static TEXT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
TEXT.get_or_init(|| {
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeKind, OpacityMask, StrokeLayer, WidthProfile};
use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, GraphicStyle, Node, NodeKind, OpacityMask, StrokeLayer, WidthProfile};
use vectorcraft_geom::{FillRule, Rect, shapes};
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
@@ -4101,6 +4107,10 @@ fn styles_text() -> &'static str {
let mut merged = Node::group(doc.alloc_id(), vec![std::sync::Arc::new(a), std::sync::Arc::new(b)]);
merged.appearance = Appearance { effects: vec![fx("pathfinder.add")], ..Appearance::default() }.into();
doc.insert(Some(layer), usize::MAX, merged).unwrap();
doc.graphic_styles.push(GraphicStyle::new("Shaded", ap));
let chained = FillLayer { opacity: 0.5, effects: vec![fx("distort.twist")], ..FillLayer::new(Paint::solid(Color::WHITE)) };
let chained = Appearance { items: vec![AppearanceItem::Fill(chained)], ..Appearance::default() };
doc.graphic_styles.push(GraphicStyle::new("Chained", chained));
let out = vectorcraft_eps::export_ai_editing(&doc, &vectorcraft_eps::AiEditingOptions::default()).unwrap();
String::from_utf8(out.program).unwrap()
})
@@ -4112,14 +4122,46 @@ fn styles_text() -> &'static str {
fn the_written_styles_fuzz_data_opens_live() {
let text = styles_text();
assert!(text.chars().count() < STYLES_FUZZ_CHARS, "{}", text.chars().count());
for mark in ["%AI9_BeginArtStyles", "%AI5_BeginLayer", "\n6 () XW", "\n1 (Anon", "(BSLeftWidthsArray)", "0 /Bool (UseEvenOdd)", "Bb"] {
assert!(text.contains(mark), "{mark}");
for mark in [
"%AI9_BeginArtStyles",
"%AI9_BeginArtStyleList",
"%AI5_BeginLayer",
"\n6 () XW",
"\n1 (Anon",
"(BSLeftWidthsArray)",
"0 /Bool (UseEvenOdd)",
"(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter",
"Bb",
] {
assert!(text.replace("\r\n", "\n").contains(mark), "{mark}");
}
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(text)].concat();
let (d, warnings, layered) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0);
assert!(layered, "{warnings:?}");
let art = d.layers[0].children().unwrap();
assert_eq!(art.iter().map(|n| n.appearance.effects.len()).collect::<Vec<_>>(), [1, 1, 1, 1, 1]);
assert!(d.graphic_style("Shaded").is_some() && d.graphic_style("Chained").is_some());
}
/// [`symbols_text`] without its layer: a symbol library.
fn symbol_library_text() -> String {
let full = symbols_text();
match (full.find("%AI5_BeginLayer"), full.find("%AI5_EndLayer--\n")) {
(Some(a), Some(b)) if a < b => format!("{}{}", &full[..a], &full[b + "%AI5_EndLayer--\n".len()..]),
_ => full,
}
}
/// The library [`ai_symbols_never_panic`] damages, as it is, opens with its symbols.
#[test]
fn the_symbol_library_fuzz_data_opens_with_its_symbols() {
let text = symbol_library_text();
assert!(!text.contains("%AI5_BeginLayer"));
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat();
let (doc, warnings, layered) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0);
assert!(!layered, "{warnings:?}");
let names: Vec<&str> = doc.symbols.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, ["S2", "S1"], "{warnings:?}");
}
/// Editing data with symbols (S2's art holds an instance of S1, F's holds non-native art), the
@@ -4212,6 +4254,9 @@ fn ai_symbol_limits_never_panic() {
ai::layer("L", &inst("(S1)", "1 0 0 -1 20 50"))
),
format!("{}{}", def("S1", &inst("(S2)", "1 0 0 -1 0 0")), def("S2", &inst("(S1)", "1 0 0 -1 0 0"))),
// Symbol libraries without layers: definitions nested too deep and a long chain.
nested,
chain,
];
for body in cases {
let data = ai::editing_data(200.0, 100.0, &body).replacen("%%EndComments", "%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n%%EndComments", 1);
+16
View File
@@ -109,6 +109,22 @@ fn a_journal_with_a_key_no_longer_read_still_replays() {
assert!(s.execute("object.move", &json!({"dx": 5, "dyy": 5})).is_err());
}
/// `Session::replay` still runs an export with an option that is no longer read, and a preset
/// file that holds one still loads and exports. `Session::execute` refuses the same options.
#[test]
fn an_export_step_or_preset_with_a_key_no_longer_read_still_runs() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let old = json!({"format": "pdf", "noSuchOption": true, "marks": {"useDocumentBleed": true}});
assert!(check_params("document.export", &old).is_err());
assert!(s.execute("document.export", &old).is_err());
s.replay("document.export", &old).unwrap();
let file = json!({"format": "vcpdfpresets", "presets": [{"name": "Old", "settings": {"standard": "none", "noLongerRead": 1, "marks": {"retired": true}}}]});
s.execute("pdf.preset.import", &json!({"data": file.to_string()})).unwrap();
check_params("document.export", &json!({"format": "pdf", "preset": "Old"})).unwrap();
s.execute("document.export", &json!({"format": "pdf", "preset": "Old"})).unwrap();
}
/// So do an effect's own parameters (#1592): a journal or a file with a key the effect doesn't
/// take, or a value of another type, replays and opens; the effect reads what it knows, clamped.
#[test]
+65
View File
@@ -0,0 +1,65 @@
//! `plugin.clearCache` without an id empties the cache of every installed plug-in and leaves their
//! settings.
//!
//! A test binary of its own: installed plug-ins are process-wide, so clearing every plug-in's cache
//! in the engine's other tests would empty the caches those tests check.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use serde_json::{Value, json};
use vectorcraft_engine::Session;
use vectorcraft_plugins::wat as tpl;
/// Sets "zoom" to "hello" and writes "hel" to the cache entry "tile".
const WRITES: &str = "(if (call $vc_settings_set (i32.const 0x2000) (i32.const 4) (i32.const 0x2100) (i32.const 5)) (then unreachable))
(if (call $vc_cache_write (i32.const 0x2004) (i32.const 4) (i32.const 0x2100) (i32.const 3)) (then unreachable))";
/// Traps unless "zoom" is "hello".
const READS: &str =
"(if (i32.ne (call $vc_settings_get (i32.const 0x2000) (i32.const 4) (i32.const 0x3000) (i32.const 16)) (i32.const 5)) (then unreachable))
(if (i32.ne (i32.load (i32.const 0x3000)) (i32.load (i32.const 0x2100))) (then unreachable))";
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
/// Installs a filter `id` of API level 2 that lists `settings` and `cache` and whose `vc_run` runs
/// `first`, in place of any module installed with its id (`replace: true`).
fn install_storing(s: &mut Session, id: &str, first: &str) {
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":["settings","cache"]}}"#);
let data = format!(r#"{} (data (i32.const 0x2000) "zoomtile") (data (i32.const 0x2100) "hello")"#, tpl::STORAGE_IMPORTS);
let wasm = wat::parse_str(tpl::with_imports(&manifest, &data, first, r#"{"objects":[]}"#)).unwrap();
let data = vectorcraft_format::base64_encode(&wasm);
run(s, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true}));
}
#[test]
fn clearing_every_cache_empties_each_plugins_cache_and_keeps_its_settings() {
let folder = std::env::temp_dir().join(format!("vc-plugin-clear-cache-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&folder);
std::fs::create_dir_all(&folder).unwrap();
let ids = ["test.clearcache.a", "test.clearcache.b"];
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 200})).unwrap();
s.attach_plugin_store(folder.clone());
for id in ids {
install_storing(&mut s, id, WRITES);
run(&mut s, "plugin.run", json!({"id": id, "ids": []}));
assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["cacheBytes"], json!(3));
}
assert_eq!(run(&mut s, "plugin.clearCache", json!({})), json!({"cleared": ids, "bytesFreed": 6}));
for id in ids {
assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["cacheBytes"], json!(0));
// The settings stay: a module that reads them runs.
install_storing(&mut s, id, READS);
run(&mut s, "plugin.run", json!({"id": id, "ids": []}));
}
// Nothing is left to clear.
assert_eq!(run(&mut s, "plugin.clearCache", json!({})), json!({"cleared": ids, "bytesFreed": 0}));
for id in ids {
run(&mut s, "plugin.remove", json!({"id": id}));
}
let _ = std::fs::remove_dir_all(&folder);
}
+90 -3
View File
@@ -599,7 +599,7 @@ proptest! {
let parsed = catch_quiet(|| vectorcraft_plugins::Manifest::parse(text.as_bytes()));
let parsed = parsed.map_err(|msg| TestCaseError::fail(format!("panicked on {text}: {msg}")))?;
if let Ok(man) = &parsed {
prop_assert!(man.permissions().iter().all(|p| p.applies(man.kind)), "{}", text);
prop_assert!(man.permissions().iter().all(|p| p.applies(man.kind, !man.commands.is_empty())), "{}", text);
let shown = vectorcraft_plugins::permissions::display_text(&man.name, vectorcraft_plugins::permissions::MAX_SHOWN_NAME);
prop_assert!(!shown.chars().any(|c| c.is_control() || matches!(c, '\u{202E}' | '\u{2066}' | '\u{200F}')), "{}", shown);
prop_assert!(shown.chars().count() <= vectorcraft_plugins::permissions::MAX_SHOWN_NAME);
@@ -617,6 +617,80 @@ proptest! {
vectorcraft_plugins::registry::revoke("fuzz.permissions");
}
/// Manifests with hostile `commands` (junk of every type, bad and repeated ids, labels with
/// control and bidirectional override characters, a `menu` naming another menu, junk
/// parameters) parse to an error or to commands whose menu labels show no hidden character and
/// that sit under Plug-ins alone; installing one and running each of its commands ends as an
/// error or an edit, never a panic, and the document then renders and exports.
#[test]
fn hostile_manifest_commands_never_crash(
kind in prop::sample::select(vec!["filter", "effect", "exporter"]),
entries in prop::collection::vec(
prop_oneof![
3 => (
prop_oneof![Just(json!("grow")), Just(json!("a_1")), Just(json!("Grow")), Just(json!("")), Just(json!("a.b")), arb_junk()],
prop_oneof![
3 => "[a-zA-Z\u{202E}\u{2066}\u{200F} ]{0,70}".prop_map(Value::from),
1 => "[a-z\u{0007}\n]{1,8}".prop_map(Value::from),
1 => arb_junk(),
],
prop_oneof![Just(Value::Null), Just(json!("File")), arb_junk()],
prop_oneof![Just(Value::Null), Just(json!({"by": {"type": "number", "min": 0, "max": 10, "default": 2}})), arb_junk()],
)
.prop_map(|(id, label, menu, params)| {
let mut c = json!({"id": id, "label": label});
if !menu.is_null() {
c["menu"] = menu;
}
if !params.is_null() {
c["params"] = params;
}
c
}),
1 => arb_junk(),
],
0..5,
),
junk in prop::option::weighted(0.1, arb_junk()),
given in prop_oneof![Just(json!({})), Just(json!({"by": 3})), arb_junk()],
) {
let mut m = json!({"id": "fuzz.commands", "name": "Fuzz \u{202E}Commands", "kind": kind});
if kind == "exporter" {
m["extensions"] = json!(["fz"]);
}
m["commands"] = junk.unwrap_or(json!(entries));
let text = m.to_string();
let parsed = catch_quiet(|| vectorcraft_plugins::Manifest::parse(text.as_bytes()));
let parsed = parsed.map_err(|msg| TestCaseError::fail(format!("panicked on {text}: {msg}")))?;
if let Ok(man) = &parsed {
for c in &man.commands {
let shown = c.shown_label();
prop_assert!(!shown.chars().any(|ch| ch.is_control() || matches!(ch, '\u{202E}' | '\u{2066}' | '\u{200F}')), "{}", shown);
prop_assert!(shown.chars().count() <= vectorcraft_plugins::permissions::MAX_SHOWN_NAME);
}
}
let mut s = rich_session();
s.execute("select.all", &json!({})).unwrap();
let module = wat::parse_str(tpl::returning(&text, r#"{"objects":[]}"#)).unwrap();
let r = catch_quiet(|| {
let mut ran = vec![];
if install(&mut s, &module).is_ok() {
for c in s.commands().into_iter().filter(|c| c.id.starts_with("plugin.fuzz.commands.")) {
ran.push((c.id, c.menu.clone()));
let _ = s.execute(c.id, &given);
}
}
ran
});
let ran = r.map_err(|msg| TestCaseError::fail(format!("panicked running {text}: {msg}")))?;
prop_assert!(ran.is_empty() || parsed.is_ok(), "a manifest that doesn't parse installed: {}", text);
for (id, menu) in &ran {
prop_assert!(menu.len() == 3 && menu[1] == "Plug-ins" && matches!(menu[0], "Object" | "Effect"), "{id}: {menu:?}");
}
renders(&s, "after hostile commands")?;
vectorcraft_plugins::registry::remove("fuzz.commands");
}
/// A plug-in of API level 2 that calls the host imports with hostile arguments (any progress
/// counts, any log level, pointers and lengths in and out of its memory, text that isn't
/// UTF-8) runs to an error or an output, never a panic, and its report stays within the caps.
@@ -681,7 +755,17 @@ proptest! {
use vectorcraft_plugins::host::STORAGE_BUDGET;
use vectorcraft_plugins::storage::Meter;
const FUNCS: [&str; 6] = ["vc_settings_get", "vc_settings_set", "vc_cache_read", "vc_cache_write", "vc_settings_delete", "vc_cache_clear"];
let folder = std::env::temp_dir().join(format!("vectorcraft-plugin-fuzz-storage-{}", std::process::id()));
// Each case starts with an empty store, and the store is deleted when the case ends, also
// when it fails.
struct Store(std::path::PathBuf);
impl Drop for Store {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let store = Store(std::env::temp_dir().join(format!("vectorcraft-plugin-fuzz-storage-{}", std::process::id())));
let _ = std::fs::remove_dir_all(&store.0);
let folder = store.0.clone();
let mut s = Session::new();
s.attach_plugin_store(folder.clone());
let storing = |id: &str| format!(r#"{{"id":"{id}","name":"F","kind":"filter","api":2,"permissions":["settings","cache"]}}"#);
@@ -704,8 +788,11 @@ proptest! {
let text: String = key.iter().map(|b| format!("\\{b:02x}")).collect();
let imports = format!(r#"{} (data (i32.const 0x2000) "{text}")"#, tpl::STORAGE_IMPORTS);
let wasm = wat::parse_str(tpl::with_imports(&storing("fuzz.storage"), &imports, &format!("(local $r i32) {body}"), r#"{"objects":[]}"#)).unwrap();
// A failed case leaves its module installed, and the cases that shrink it install others
// under the same id.
let r = catch_quiet(|| {
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": grant})).map_err(|e| e.to_string())?;
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": grant, "replace": true}))
.map_err(|e| e.to_string())?;
let p = vectorcraft_plugins::registry::get("fuzz.storage").ok_or("not installed")?;
p.run(b"{\"objects\":[]}", b"{}").map_err(|e| e.to_string())
});
+19 -14
View File
@@ -122,16 +122,17 @@
//! definitions are at most the reader's 4,096. In a symbol's art, an instance's matrix and
//! a gradient's take the art to the canvas's space turned y up about art space's origin there.
//! - Art styles, as the reader reads them (`style.rs`, `read/ops.rs`): a path, compound path or
//! group with live effects is written as its drawn look (`u`, its vector art as its geometry
//! effects leave it, without shadows, glows or feathers, `U`, its transparency, `0 0 Xd`,
//! `6 () XW`), then the object itself on `%_` lines (its points painted
//! group with live effects or linked to a graphic style is written as its drawn look (`u`, its
//! vector art as its geometry effects leave it, without shadows, glows or feathers, `U`, its
//! transparency, `0 0 Xd`, `6 () XW`), then the object itself on `%_` lines (its points painted
//! with nothing, or with its gradient fill; a group with its members) with its name and opacity
//! mask, then `1 (style) XW`. The styles are written in a non-printing block after the symbols
//! (`%AI9_BeginArtStyles`, each `/KnownStyle :` with its name and a filter tree: a chain of the
//! stack of fills and strokes with the contents among them, the effects and the blend filter). A
//! fill filter fills by the non-zero rule (`0 /Bool (UseEvenOdd)`), so a filled object that fills by
//! the even-odd rule is written as its drawn look. A style is written only when the reader reads it
//! back as the object's appearance.
//! stack of fills and strokes with the contents among them, the effects and the blend filter), the
//! Graphic Styles panel's first; the panel's order follows the palette (`%AI9_BeginArtStyleList`).
//! A fill filter fills by the non-zero rule (`0 /Bool (UseEvenOdd)`), so a filled object that
//! fills by the even-odd rule is written as its drawn look. A style is written only when the
//! reader reads it back as the object's appearance (or the graphic style's).
//!
//! Lines end in CR LF. Strings are written in UTF-8. Art space is ruler space: y up, its origin at
//! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the
@@ -166,8 +167,9 @@
//! gradient swatches, gradient swatches of more than 256 stops, swatches of patterns written as
//! tiles and the placement of pattern swatches that move, turn or scale their pattern, the opacity and blending modes of single fills and strokes, opacity masks on
//! layers and opacity masks nested too deep, and, on objects written without an art style, live
//! effects other than geometry effects left out; graphic styles, links to them and every art style
//! on a large canvas are left out.
//! effects other than geometry effects left out; graphic styles the reader wouldn't read, links to
//! graphic styles on objects not written with them, and every art style on a large canvas, are left
//! out.
//!
//! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with
//! the version numbers of the newest files users sent ([`private_data`]).
@@ -249,7 +251,8 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditi
// The objects that may keep their effects in an art style, as they are before baking (not on
// a large canvas, whose scale the styles' strokes and effects would need).
let styled = writer::styled_objects(doc);
let graphic_styles = writer::has_graphic_styles(doc);
let own_styles = doc.graphic_styles != Document::new(1.0, 1.0).graphic_styles;
let links = writer::style_links(doc);
// Live geometry effects as their result, as the PDF part draws them.
let baked = baked(doc);
let doc = baked.as_ref().unwrap_or(doc);
@@ -283,15 +286,16 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditi
w.reserve_swatch_gradients();
if scale == 1.0 {
w.styled = styled;
} else if !styled.is_empty() {
w.panel_styles();
} else if !styled.is_empty() || own_styles || links > 0 {
w.warnings.push(writer::LARGE_CANVAS_STYLES.to_string());
}
if graphic_styles {
w.warnings.push(writer::GRAPHIC_STYLES.to_string());
}
for layer in &doc.layers {
w.layer(layer);
}
if scale == 1.0 {
w.lost_links(links);
}
// The symbols no instance uses too; their art adds definitions.
w.symbols();
// The palette first: its gradient and pattern swatches add definitions.
@@ -315,6 +319,7 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditi
w.symbol_definitions(&mut s);
w.art_styles(&mut s);
s.push_str(&palette);
w.art_style_list(&mut s);
w.symbol_list(&mut s);
artboards(&mut s, doc, to_art, origin, &library);
if !w.texts.is_empty() {
+500 -39
View File
@@ -4715,6 +4715,48 @@ fn symbols_painting_patterns_through_brush_art_are_placed_as_art() {
assert_eq!(instances(&back.layers[0]), 0, "the instance is its art");
}
/// A symbol first defined from the drawn look of a group with a style, through a member's scatter
/// brush, still warns that its art's own effects are left out, and so does a later instance of it.
#[test]
fn a_symbol_defined_inside_a_drawn_look_warns_of_its_own_effects() {
let mut doc = Document::new(200.0, 100.0);
let mut shadowed = square(&mut doc, Rect::new(0.0, 0.0, 6.0, 6.0), Color::BLACK);
shadowed.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(black_fill(), Paint::None, 0.0) }.into();
doc.symbols.push(Symbol { dynamic: false, name: "Shadowed".into(), art: Arc::new(shadowed) });
let dot = square(&mut doc, Rect::new(0.0, 0.0, 6.0, 6.0), Color::BLACK);
let placed = instance(&mut doc, "Shadowed", Affine::translate((8.0, 0.0)));
let art = group(&mut doc, vec![dot, placed], false);
let brush = vectorcraft_brush::Brush {
name: "Scattered".into(),
kind: vectorcraft_brush::BrushKind::Scatter(vectorcraft_brush::Scatter {
art,
size: (100.0, 100.0),
spacing: (100.0, 100.0),
scatter: (0.0, 0.0),
rotation: (0.0, 0.0),
colorization: vectorcraft_brush::Colorization::None,
..Default::default()
}),
};
vectorcraft_brush::store(&mut doc, &[brush]);
let mut line =
Node::path(doc.alloc_id(), shapes::line((10.0, 50.0).into(), (90.0, 50.0).into()), Appearance::basic(Paint::None, solid(Color::BLACK), 1.0));
line.appearance.stroke_mut().unwrap().brush = Some("Scattered".into());
let edge = square(&mut doc, Rect::new(10.0, 60.0, 30.0, 80.0), LEAF);
let mut styled = group(&mut doc, vec![line, edge], false);
styled.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into();
put(&mut doc, named(styled, "Styled"));
let out = export_ai_editing(&doc, &opts()).unwrap();
let p = shown(&out.program);
assert!(p.contains("%AI14_BeginSymbol") && p.contains("1 (Anon 1) XW"), "{p}");
assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings);
let later = instance(&mut doc, "Shadowed", Affine::translate((150.0, 20.0)));
put(&mut doc, later);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings);
}
/// A symbol whose stroke is written as its outline (an outside stroke): a scaled instance, a scaled
/// instance of a symbol holding it and a turned and mirrored instance are written as their art,
/// which keeps the stroke the canvas draws; a moved instance stays an instance.
@@ -5388,6 +5430,33 @@ fn styles_document() -> Document {
let mut merged = group(&mut doc, vec![a, b], false);
merged.appearance = Appearance { effects: vec![fx("pathfinder.add", serde_json::json!({}))], ..Appearance::default() }.into();
add(&mut doc, named(merged, "Merged"));
// A graphic style of its own, and an object linked to one.
let mut fill_top = FillLayer::new(solid(Color::rgb(0.2, 0.2, 0.9)));
fill_top.opacity = 0.5;
fill_top.blend = BlendMode::Screen;
fill_top.effects = vec![fx("distort.twist", serde_json::json!({"angle": 30.0}))];
let style_ap = Appearance {
items: vec![
AppearanceItem::Fill(FillLayer::new(solid(Color::rgb(0.9, 0.9, 0.1)))),
AppearanceItem::Fill(fill_top),
AppearanceItem::Stroke(StrokeLayer::new(solid(Color::BLACK), 3.0)),
],
effects: vec![fx("warp.arc", serde_json::json!({"bend": 30.0})), shadow],
// The contents over every fill and stroke.
contents_index: Some(3),
};
let mut layered = vectorcraft_doc::GraphicStyle::new("Layered", style_ap);
layered.id = 9;
layered.opacity = 0.8;
doc.graphic_styles.push(layered);
let sunshine = doc.graphic_style("Sunshine").unwrap().clone();
let mut linked_obj = Node::path(
doc.alloc_id(),
shapes::rectangle(Rect::new(170.0, 130.0, 220.0, 170.0)),
sunshine.appearance_on(&Node::group(vectorcraft_doc::NodeId(0), vec![])).into_owned(),
);
linked_obj.extras_mut().graphic_style = Some(sunshine.id);
add(&mut doc, named(linked_obj, "Sunny"));
doc
}
@@ -5511,12 +5580,24 @@ fn near_lines(got: &str, want: &str) {
}
#[test]
fn objects_with_live_effects_are_read_back() {
fn objects_with_live_effects_and_graphic_styles_are_read_back() {
let doc = styles_document();
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings);
assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS || w == writer::PANEL_STYLES), "{:?}", out.warnings);
let back = read_back(&doc);
same_styles(&back, &doc);
// The panel's styles, in its order, with their appearance and transparency.
let names = |d: &Document| d.graphic_styles.iter().map(|g| g.name.clone()).collect::<Vec<_>>();
assert_eq!(names(&back), names(&doc));
for (g, w) in back.graphic_styles.iter().zip(doc.graphic_styles.iter()) {
assert!(writer::same_appearance(&g.appearance, &w.appearance), "{}:\n got {:?}\n want {:?}", w.name, g.appearance, w.appearance);
assert_eq!(
(r3(f64::from(g.opacity)), g.blend, g.isolate, g.knockout),
(r3(f64::from(w.opacity)), w.blend, w.isolate, w.knockout),
"{}",
w.name
);
}
// A group's members come back as they were.
let members = |d: &Document, name: &str| {
let mut out = vec![];
@@ -5528,13 +5609,39 @@ fn objects_with_live_effects_are_read_back() {
same_lines(&members(&back, "Merged"), &members(&doc, "Merged"));
}
/// In a document with only a new document's graphic styles, an object linked to one of them keeps
/// its link: the panel's styles are written.
#[test]
fn a_link_to_a_built_in_graphic_style_is_read_back() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let sunshine = doc.graphic_style("Sunshine").unwrap().clone();
let ap = sunshine.appearance_on(&Node::group(vectorcraft_doc::NodeId(0), vec![])).into_owned();
// Without a link, a new document's styles are not written.
let mut plain = doc.clone();
let unlinked = Node::path(plain.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 50.0)), ap.clone());
plain.insert(Some(layer), usize::MAX, unlinked).unwrap();
assert!(!program(&plain).contains("%AI9_BeginArtStyleList"));
let mut sunny = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 50.0)), ap);
sunny.extras_mut().graphic_style = Some(sunshine.id);
doc.insert(Some(layer), usize::MAX, named(sunny, "Sunny")).unwrap();
assert_eq!(doc.graphic_styles, Document::new(1.0, 1.0).graphic_styles);
let back = read_back(&doc);
same_styles(&back, &doc);
let n = &back.layers[0].children().unwrap()[0];
assert_eq!(n.extras().graphic_style.and_then(|id| back.graphic_style_by_id(id)).map(|g| g.name.as_str()), Some("Sunshine"));
}
#[test]
fn art_styles_are_written_in_their_forms() {
let p = program_shown(&styles_document());
// The styles in a non-printing block after the symbols' and before the palette.
let styles = p.find("%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyles\n/KnownStyle :\n(Anon 1) /Name ,\n/ActiveStyle :\n").unwrap();
// The styles in a non-printing block after the symbols' and before the palette; the panel's in
// its order first, then the objects' own.
let styles = p.find("%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyles\n/KnownStyle :\n(Default Graphic Style) /Name ,\n/ActiveStyle :\n").unwrap();
assert!(styles < p.find("%AI5_BeginPalette").unwrap(), "{p}");
assert!(!p.contains("%AI9_BeginArtStyleList"), "{p}");
let list = "%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyleList\n(Default Graphic Style)\n(Black Outline)\n(Heavy Ink)\n(Sunshine)\n(Layered)\n%AI9_EndArtStyleList\n%AI5_End_NonPrinting--\n";
let at = p.find(list).unwrap();
assert!(p.find("%AI5_EndPalette").unwrap() < at && at < p.find("%AI9_BeginDocumentData").unwrap(), "{p}");
// A drop shadow on the object, after its stack and before its transparency.
assert!(
p.contains(
@@ -5548,6 +5655,8 @@ fn art_styles_are_written_in_their_forms() {
assert!(p.contains("U\n0 1 0 2 0 Xy\n0 0 Xd\n6 () XW\n%_0 XR\n%_0 1 0 0 0 Xy\n%_10 -10 m\n"), "{p}");
assert!(p.contains("%_n\n%_/ArtDictionary :\n%_/XMLUID : (Shadowed) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Anon 1) XW\n"), "{p}");
assert!(p.contains("%_n\n%_/ArtDictionary :\n%_/XMLUID : (Offset) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Anon "), "{p}");
// The object linked to a graphic style names it.
assert!(p.contains("%_/XMLUID : (Sunny) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Sunshine) XW\n"), "{p}");
// A group's members on `%_` lines, then its name and style.
assert!(p.contains("6 () XW\n%_u\n"), "{p}");
assert!(p.contains("%_U\n%_/ArtDictionary :\n%_/XMLUID : (Merged) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Anon "), "{p}");
@@ -5567,8 +5676,9 @@ fn art_styles_are_written_in_their_forms() {
}
/// Objects whose appearance the reader wouldn't read with their style are written as their
/// drawn look, with the warnings they were written with before; and a document without effects has
/// no art styles.
/// drawn look, with the warnings they were written with before; graphic styles it wouldn't read
/// are left out with a warning; and a document without effects or styles of its own has no art
/// styles.
#[test]
fn appearances_the_reader_wouldnt_read_are_their_drawn_look() {
let mut doc = Document::new(200.0, 100.0);
@@ -5596,10 +5706,41 @@ fn appearances_the_reader_wouldnt_read_are_their_drawn_look() {
assert!(!p.contains("%AI9_BeginArtStyles") && !p.lines().any(|l| l.starts_with("1 (")), "{p}");
let back = read_back(&doc);
assert!(back.layers[0].children().unwrap().iter().all(|n| n.appearance.effects.is_empty()));
// A graphic style with a gradient: left out, the others listed.
let ramp = Gradient { stops: vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)], ..Gradient::default() };
let graded = Appearance::basic(Paint::Gradient(Box::new(GradientPaint::new(ramp))), Paint::None, 0.0);
doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Graded", graded));
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings);
let p = String::from_utf8(out.program).unwrap().replace("\r\n", "\n");
assert!(p.contains("(Sunshine)\n%AI9_EndArtStyleList") && !p.contains("(Graded)"), "{p}");
// Nothing styled: no art styles.
assert!(!program(&sample()).contains("ArtStyle"));
}
/// A graphic style's opacity is read back within the written number's precision: the style and an
/// object's link to it stay.
#[test]
fn a_graphic_styles_opacity_is_compared_at_the_written_precision() {
let mut doc = Document::new(200.0, 100.0);
let ap = Appearance::basic(solid(Color::BLACK), Paint::None, 1.0);
let mut g = vectorcraft_doc::GraphicStyle::new("Fine", ap.clone());
g.id = 1;
g.opacity = 0.123456;
*doc.graphic_styles = vec![g];
let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 50.0)), ap);
n.opacity = 0.123456;
n.extras_mut().graphic_style = Some(1);
let layer = doc.layers[0].id;
doc.insert(Some(layer), usize::MAX, n).unwrap();
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings);
let back = read_back(&doc);
assert_eq!(back.graphic_styles.len(), 1);
assert!((back.graphic_styles[0].opacity - 0.123456).abs() < 1e-3);
assert_eq!(back.layers[0].children().unwrap()[0].extras().graphic_style, Some(back.graphic_styles[0].id));
}
/// A compound shape draws as one area, which an art style's object wouldn't keep: it and a group
/// holding one are written as their drawn look, with a warning.
#[test]
@@ -5630,6 +5771,45 @@ fn compound_shapes_are_not_written_with_an_art_style() {
}
}
/// Graphic styles the reader's style list wouldn't hold (a name over its limit, styles past its
/// count) are left out with a warning, and an object linked to one has no link.
#[test]
fn graphic_styles_past_the_lists_limits_are_left_out() {
let plain = || Appearance::basic(solid(Color::BLACK), Paint::None, 1.0);
let styled = |name: String, id: u32| {
let mut g = vectorcraft_doc::GraphicStyle::new(name, plain());
g.id = id;
g
};
// A name one byte over the limit, among others.
let mut doc = Document::new(200.0, 100.0);
let long = "n".repeat(1025);
*doc.graphic_styles = vec![styled("Before".into(), 1), styled(long.clone(), 2), styled("After".into(), 3)];
let mut linked = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 50.0)), plain());
linked.extras_mut().graphic_style = Some(2);
let layer = doc.layers[0].id;
doc.insert(Some(layer), usize::MAX, linked).unwrap();
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings);
assert!(!shown(&out.program).contains(&long));
let back = read_back(&doc);
let names: Vec<&str> = back.graphic_styles.iter().map(|g| g.name.as_str()).collect();
assert_eq!(names, ["Before", "After"]);
assert_eq!(back.layers[0].children().unwrap()[0].extras().graphic_style, None);
// A name at the limit is kept.
let at_limit = "n".repeat(1024);
doc.graphic_styles[1].name = at_limit.clone();
assert!(read_back(&doc).graphic_styles.iter().any(|g| g.name == at_limit));
// Styles past the count.
let mut many = Document::new(200.0, 100.0);
*many.graphic_styles = (1..=4097).map(|i| styled(format!("Style {i}"), i)).collect();
let out = export_ai_editing(&many, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings);
let back = read_back(&many);
assert_eq!(back.graphic_styles.len(), 4096);
assert_eq!(back.graphic_styles.last().map(|g| g.name.as_str()), Some("Style 4096"));
}
/// A member of a group whose Transform effect moves it is written where baking put it, not with
/// its own effects where it was.
#[test]
@@ -5781,7 +5961,8 @@ fn read_one(make: impl FnOnce(&mut Document) -> Node) -> (Arc<Node>, Vec<String>
/// A filled path or compound path with live effects that fills by the even-odd rule is written as its
/// drawn look with a warning, since a style's fill fills by the non-zero rule. A stroke-only object
/// keeps its style and its own fill rule.
/// keeps its style and its own fill rule. A filled even-odd object linked to a graphic style isn't
/// written with that style, and its link is left out with a warning.
#[test]
fn a_filled_even_odd_object_is_written_as_its_drawn_look() {
let mut doc = Document::new(300.0, 100.0);
@@ -5810,9 +5991,28 @@ fn a_filled_even_odd_object_is_written_as_its_drawn_look() {
let back = read_back(&doc);
same_styles(&back, &doc);
assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::Compound { rule: FillRule::EvenOdd, .. }));
let mut doc = Document::new(200.0, 100.0);
let ap = Appearance::basic(black_fill(), Paint::None, 0.0);
let mut g = vectorcraft_doc::GraphicStyle::new("Solid", ap.clone());
g.id = 7;
doc.graphic_styles.push(g);
let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap);
n.extras_mut().graphic_style = Some(7);
if let NodeKind::Path { rule, .. } = &mut n.kind {
*rule = FillRule::EvenOdd;
}
put(&mut doc, n);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings);
let back = read_back(&doc);
let n = &back.layers[0].children().unwrap()[0];
assert!(matches!(n.kind, NodeKind::Path { rule: FillRule::EvenOdd, .. }), "{n:?}");
assert_eq!(n.extras().graphic_style, None);
}
/// A compound path filled by the non-zero rule keeps its effects and its gradient fill.
/// A compound path filled by the non-zero rule keeps its effects, its gradient fill and its link to
/// a graphic style.
#[test]
fn a_compound_path_keeps_its_style() {
let mut doc = Document::new(300.0, 100.0);
@@ -5830,8 +6030,16 @@ fn a_compound_path_keeps_its_style() {
let ap = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(graded, Paint::None, 0.0) };
let n = named(non_zero(ring(&mut doc, ap)), "Graded");
put(&mut doc, n);
let sunshine = doc.graphic_style("Sunshine").unwrap().clone();
let ap = sunshine.appearance_on(&Node::group(vectorcraft_doc::NodeId(0), vec![])).into_owned();
let mut linked_ring = named(ring(&mut doc, ap), "Linked");
if let NodeKind::Compound { rule, .. } = &mut linked_ring.kind {
*rule = FillRule::NonZero;
}
linked_ring.extras_mut().graphic_style = Some(sunshine.id);
put(&mut doc, linked_ring);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings);
assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS || w == writer::STYLE_LINKS), "{:?}", out.warnings);
let back = read_back(&doc);
same_styles(&back, &doc);
assert!(back.layers[0].children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Compound { .. })));
@@ -5857,36 +6065,136 @@ fn a_group_holding_an_image_is_written_as_its_drawn_look() {
assert!(im.link.is_some());
}
/// Graphic styles and links to them are left out with a warning: an object linked to a style
/// keeps its effects in a style of its own. A new document's styles without a link don't warn.
/// A link to a graphic style that isn't written warns; one that is written doesn't.
#[test]
fn graphic_styles_and_links_are_left_out_with_a_warning() {
let warns = |doc: &Document| export_ai_editing(doc, &opts()).unwrap().warnings.iter().any(|w| w == writer::GRAPHIC_STYLES);
assert!(!warns(&Document::new(200.0, 100.0)));
// A style of the document's own.
let mut own = Document::new(200.0, 100.0);
own.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Mine", Appearance::basic(black_fill(), Paint::None, 0.0)));
assert!(warns(&own));
assert!(!shown(&export_ai_editing(&own, &opts()).unwrap().program).contains("ArtStyle"));
assert_eq!(read_back(&own).graphic_style("Mine"), None);
// A link to a new document's style, on an object with an effect.
let mut linked = Document::new(200.0, 100.0);
let id = linked.graphic_style("Sunshine").unwrap().id;
let mut n = plain_rect(&mut linked, "Linked", vec![drop_shadow()]);
n.extras_mut().graphic_style = Some(id);
put(&mut linked, n);
assert!(warns(&linked));
let back = read_back(&linked);
let n = &back.layers[0].children().unwrap()[0];
assert_eq!(n.extras().graphic_style, None);
assert_eq!(n.appearance.effects.len(), 1);
fn a_link_left_out_warns() {
let warns = |make: &dyn Fn(&mut Document, u32) -> Node| {
let mut doc = Document::new(200.0, 100.0);
let ap = Appearance {
items: vec![AppearanceItem::Fill(FillLayer::new(black_fill())), AppearanceItem::Fill(FillLayer::new(solid(Color::WHITE)))],
..Appearance::default()
};
let mut layered = vectorcraft_doc::GraphicStyle::new("Layered", ap);
layered.id = 7;
doc.graphic_styles.push(layered);
let mut plain = vectorcraft_doc::GraphicStyle::new("Plain", Appearance::basic(black_fill(), Paint::None, 0.0));
plain.id = 8;
doc.graphic_styles.push(plain);
let n = make(&mut doc, 7);
put(&mut doc, n);
let warned = export_ai_editing(&doc, &opts()).unwrap().warnings.iter().any(|w| w == writer::STYLE_LINKS);
(warned, read_back(&doc))
};
let linked = |mut n: Node, id: u32| {
n.extras_mut().graphic_style = Some(id);
n
};
// A style of two fills: a path isn't read with it.
let (warned, back) = warns(&|doc, id| {
let ap = doc.graphic_style_by_id(id).unwrap().appearance.clone();
linked(Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap), id)
});
assert!(warned);
assert_eq!(back.layers[0].children().unwrap()[0].extras().graphic_style, None);
assert!(back.graphic_style("Layered").is_some());
// An image, a symbol instance and a knockout shape are never written with a style.
let (warned, _) = warns(&|doc, _| {
let art = square(doc, Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK);
doc.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) });
linked(Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((50.0, 20.0)) }), 8)
});
assert!(warned);
let (warned, _) = warns(&|doc, _| {
let mut n = plain_rect(doc, "Out", vec![]);
n.knockout_shape = true;
linked(n, 8)
});
assert!(warned);
// A group with a member the style can't hold.
let (warned, _) = warns(&|doc, _| {
let member = plain_rect(doc, "Member", vec![drop_shadow()]);
linked(group(doc, vec![member], false), 8)
});
assert!(warned);
// A group with an effect and a member linked to a style: the group keeps its effect, and the
// member's link is left out.
let (warned, back) = warns(&|doc, _| {
let member = linked(plain_rect(doc, "Member", vec![]), 8);
let mut g = group(doc, vec![member], false);
g.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into();
g
});
assert!(warned);
let g = &back.layers[0].children().unwrap()[0];
assert_eq!(g.appearance.effects.len(), 1, "{g:?}");
assert_eq!(g.children().unwrap()[0].extras().graphic_style, None);
// A group whose link isn't written: its member keeps its own.
let (warned, back) = warns(&|doc, id| {
let plain = doc.graphic_style_by_id(id + 1).unwrap().appearance.clone();
let member = linked(Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), plain), id + 1);
linked(group(doc, vec![member], false), id)
});
assert!(warned);
let g = &back.layers[0].children().unwrap()[0];
assert_eq!(g.extras().graphic_style, None);
let member = g.children().unwrap()[0].extras().graphic_style.unwrap();
assert_eq!(back.graphic_style_by_id(member).map(|s| s.name.as_str()), Some("Plain"));
// A link in a symbol's art.
let mut in_symbol = Document::new(200.0, 100.0);
let id = in_symbol.graphic_style("Sunshine").unwrap().id;
let mut art = square(&mut in_symbol, Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK);
art.extras_mut().graphic_style = Some(id);
in_symbol.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) });
assert!(warns(&in_symbol));
let (warned, _) = warns(&|doc, _| {
let art = linked(square(doc, Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK), 8);
doc.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) });
Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((50.0, 20.0)) })
});
assert!(warned);
// A link that is kept.
let (warned, back) = warns(&|doc, id| {
let plain = doc.graphic_style_by_id(id + 1).unwrap().appearance.clone();
linked(Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), plain), id + 1)
});
assert!(!warned);
let id = back.layers[0].children().unwrap()[0].extras().graphic_style.unwrap();
assert_eq!(back.graphic_style_by_id(id).map(|g| g.name.as_str()), Some("Plain"));
}
/// A panel with no style written warns that a new document's styles come back.
#[test]
fn an_empty_graphic_styles_panel_warns() {
let mut doc = Document::new(200.0, 100.0);
doc.graphic_styles.clear();
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::PANEL_EMPTY), "{:?}", out.warnings);
assert!(!shown(&out.program).contains("ArtStyle"));
assert_eq!(read_back(&doc).graphic_styles, Document::new(1.0, 1.0).graphic_styles);
// Every style left out.
let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)];
let graded = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Graded", Appearance::basic(graded, Paint::None, 0.0)));
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::PANEL_EMPTY) && out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings);
// A new document's styles aren't written and don't warn.
let out = export_ai_editing(&Document::new(200.0, 100.0), &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::PANEL_EMPTY), "{:?}", out.warnings);
}
/// A second style of one name is left out with a warning, and an object linked to it has no link.
#[test]
fn a_graphic_style_of_a_name_another_has_is_left_out() {
let mut doc = Document::new(200.0, 100.0);
let style = |id: u32, c: Color| {
let mut g = vectorcraft_doc::GraphicStyle::new("Twin", Appearance::basic(solid(c), Paint::None, 0.0));
g.id = id;
g
};
*doc.graphic_styles = vec![style(1, Color::BLACK), style(2, Color::WHITE)];
let mut n = styled_rect(&mut doc, "Second", Rect::new(10.0, 10.0, 50.0, 40.0), solid(Color::WHITE), Paint::None, vec![]);
n.extras_mut().graphic_style = Some(2);
put(&mut doc, n);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::PANEL_STYLES) && out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings);
let back = read_back(&doc);
assert_eq!(back.graphic_styles.len(), 1);
assert_eq!(back.graphic_styles[0].appearance.fill().and_then(|f| f.paint.color()), Some(Color::BLACK));
assert_eq!(back.layers[0].children().unwrap()[0].extras().graphic_style, None);
}
/// A path or compound path with no points writes nothing, with or without a style; a group with no
@@ -6011,6 +6319,93 @@ fn every_effect_setting_written_on_an_object_is_read_back() {
assert!(matches!(&spot.appearance.fill().unwrap().paint, Paint::Solid { swatch: Some(s), .. } if s == "Ink"), "{spot:?}");
}
/// Each effect a graphic style can hold, in a style of its own, reads back with its settings, whatever
/// the writer's conversions to the file's units.
#[test]
fn every_effect_a_graphic_style_holds_is_read_back() {
use serde_json::json;
let mut cases = vec![
(
"distort.transform",
json!({"scaleH": 50.0, "scaleV": 150.0, "moveH": 12.5, "moveV": -7.5, "rotate": 30.0, "copies": 2, "reference": 8, "reflectX": true, "objects": false, "strokes": true, "patterns": true}),
),
("distort.freeDistort", json!({"corners": [[0.1, 0.05], [0.9, 0.1], [1.0, 1.0], [0.0, 0.8]]})),
("distort.puckerBloat", json!({"amount": -40.0})),
("distort.puckerBloat", json!({"amount": 60.0})),
("distort.twist", json!({"angle": -45.0})),
("distort.zigZag", json!({"size": 7.5, "relative": false, "ridges": 6, "points": "corner"})),
("distort.zigZag", json!({"size": 25.0, "relative": true, "ridges": 3, "points": "smooth"})),
("blur.gaussian", json!({"radius": 2.5})),
];
let warps = [
"arc",
"arcLower",
"arcUpper",
"arch",
"bulge",
"shellLower",
"shellUpper",
"flag",
"wave",
"fish",
"rise",
"fisheye",
"inflate",
"squeeze",
"twist",
];
let ids: Vec<String> = warps.iter().map(|w| format!("warp.{w}")).collect();
for id in &ids {
cases.push((id, json!({"bend": 30.0, "horizontal": -10.0, "vertical": 20.0, "orientation": "vertical"})));
cases.push((id, json!({"bend": -30.0, "horizontal": 0.0, "vertical": 0.0, "orientation": "horizontal"})));
}
for (id, params) in cases {
let effect = fx(id, params.clone());
let mut doc = Document::new(200.0, 100.0);
let mut g = vectorcraft_doc::GraphicStyle::new(
"Held",
Appearance { effects: vec![effect.clone()], ..Appearance::basic(black_fill(), Paint::None, 0.0) },
);
g.id = 3;
*doc.graphic_styles = vec![g];
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{id} {params}: {:?}", out.warnings);
let back = read_back(&doc);
let got = &back.graphic_styles[0].appearance.effects;
assert_eq!(got.len(), 1, "{id} {params}");
assert_eq!(got[0].id, effect.id);
let (g, w) = (vectorcraft_effects::merged_params(&got[0].id, &got[0].params), vectorcraft_effects::merged_params(&effect.id, &effect.params));
for (key, value) in w.as_object().unwrap() {
// Random isn't written, and a style with it isn't read; its seed is the default.
let read = g.get(key).unwrap_or(&serde_json::Value::Null);
match value {
serde_json::Value::Array(rows) => {
let got = read.as_array().unwrap();
assert_eq!(got.len(), rows.len(), "{id}");
for (r, w) in got.iter().zip(rows) {
for (r, w) in r.as_array().unwrap().iter().zip(w.as_array().unwrap()) {
assert!((r.as_f64().unwrap() - w.as_f64().unwrap()).abs() < 6e-3, "{id} {key}: {read} for {value}");
}
}
}
serde_json::Value::Number(v) => assert!((read.as_f64().unwrap() - v.as_f64().unwrap()).abs() < 6e-3, "{id} {key}: {read} for {v}"),
serde_json::Value::String(v) => assert_eq!(read.as_str().map(str::to_ascii_lowercase), Some(v.to_ascii_lowercase()), "{id} {key}"),
_ => assert_eq!(read, value, "{id} {key}"),
}
}
}
// A Zig Zag with its 0 to 1 settings written under the size's own key, and a turn in radians.
let mut doc = Document::new(200.0, 100.0);
let effects = vec![
fx("distort.zigZag", json!({"size": 7.5, "relative": false, "ridges": 6, "points": "corner"})),
fx("distort.transform", json!({"moveV": -7.5, "rotate": 30.0})),
];
doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Forms", Appearance { effects, ..Appearance::default() }));
let p = program_shown(&doc);
assert!(p.contains("7.5 /Real (amount) ,") && p.contains("0 /Real (roundness) ,"), "{p}");
assert!(p.contains("7.5 /Real (moveV_Pts) ,") && p.contains("0.5236 /Real (rotate_Radians) ,"), "{p}");
}
/// Every kind of appearance a style isn't written for is written as its drawn look, with a warning.
#[test]
fn appearances_written_as_their_drawn_look() {
@@ -6152,6 +6547,60 @@ fn appearances_written_as_their_drawn_look() {
}
}
/// A link to a graphic style whose Transform moves the outline is kept on a path the reader reads
/// that style on, and left out with a warning on a path with an opacity mask, which keeps its mask.
#[test]
fn a_link_to_a_style_that_moves_the_outline_is_kept_where_the_reader_reads_it() {
let make = |masked: bool| {
let mut doc = Document::new(200.0, 100.0);
let moved = fx("distort.transform", serde_json::json!({"copies": 0, "scaleH": 50.0}));
let ap = Appearance { effects: vec![moved], ..Appearance::basic(black_fill(), Paint::None, 0.0) };
let mut g = vectorcraft_doc::GraphicStyle::new("Moved", ap.clone());
g.id = 9;
doc.graphic_styles.push(g);
let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap);
n.extras_mut().graphic_style = Some(9);
if masked {
let art = square(&mut doc, Rect::new(10.0, 10.0, 50.0, 25.0), Color::gray(0.5));
n.extras_mut().mask = Some(OpacityMask { art: Arc::new(art), clip: true, invert: false, disabled: false, linked: true }.into());
}
put(&mut doc, n);
doc
};
let doc = make(false);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings);
let back = read_back(&doc);
let n = &back.layers[0].children().unwrap()[0];
assert_eq!(n.extras().graphic_style.and_then(|id| back.graphic_style_by_id(id)).map(|g| g.name.as_str()), Some("Moved"));
let doc = make(true);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings);
let back = read_back(&doc);
let n = &back.layers[0].children().unwrap()[0];
assert_eq!(n.extras().graphic_style, None);
assert!(n.extras().mask.is_some(), "{n:?}");
}
/// Twist and Transform on an object that isn't linked to a graphic style are not written as a style
/// of its own: the object reads back without live effects.
#[test]
fn distort_effects_on_an_unlinked_object_write_no_style() {
let cases = [fx("distort.twist", serde_json::json!({"angle": 30.0})), fx("distort.transform", serde_json::json!({"copies": 0, "moveH": 5.0}))];
for e in cases {
let what = e.id.clone();
let mut doc = Document::new(200.0, 100.0);
let n = plain_rect(&mut doc, "D", vec![e]);
put(&mut doc, n);
let p = shown(&export_ai_editing(&doc, &opts()).unwrap().program);
assert!(!p.contains("%AI9_BeginArtStyles") && !p.contains("(Anon 1) XW"), "{what}:\n{p}");
let back = read_back(&doc);
let n = &back.layers[0].children().unwrap()[0];
assert!(n.appearance.effects.is_empty(), "{what}: {n:?}");
}
}
/// A gradient fill that fits its object is placed on the outline its geometry effects draw: an
/// object with Offset Path reads back with the gradient a plain object of the drawn size has.
#[test]
@@ -6450,14 +6899,26 @@ fn a_styled_objects_gradient_is_placed_as_a_plain_objects_is() {
}
}
/// On a large canvas an object with an effect warns and comes back as its drawn look at its place.
/// On a large canvas a document with only graphic styles, an object linked to one and an object with
/// an effect all warn, and the object comes back as its drawn look at its place.
#[test]
fn a_large_canvas_writes_every_object_as_its_look() {
let warns = |doc: &Document| export_ai_editing(doc, &opts()).unwrap().warnings.iter().any(|w| w == writer::LARGE_CANVAS_STYLES);
let mut styles_only = Document::new(20000.0, 100.0);
styles_only.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Mine", Appearance::basic(black_fill(), Paint::None, 0.0)));
assert!(warns(&styles_only));
assert_eq!(read_back(&styles_only).graphic_style("Mine"), None);
let mut plain = Document::new(20000.0, 100.0);
let n = plain_rect(&mut plain, "None", vec![]);
put(&mut plain, n);
assert!(!warns(&plain));
let mut linked_doc = Document::new(20000.0, 100.0);
let id = linked_doc.graphic_style("Sunshine").unwrap().id;
let mut n = plain_rect(&mut linked_doc, "Linked", vec![]);
n.extras_mut().graphic_style = Some(id);
put(&mut linked_doc, n);
assert!(warns(&linked_doc));
assert_eq!(read_back(&linked_doc).layers[0].children().unwrap()[0].extras().graphic_style, None);
let mut shaded = Document::new(20000.0, 100.0);
let n = plain_rect(&mut shaded, "Shaded", vec![drop_shadow()]);
put(&mut shaded, n);
+2 -2
View File
@@ -22,9 +22,9 @@ mod symbols;
mod texts;
mod variables;
pub(super) use styles::{LARGE_CANVAS_STYLES, style_links, styled_objects};
#[cfg(test)]
pub(super) use styles::same_appearance;
pub(super) use styles::{GRAPHIC_STYLES, LARGE_CANVAS_STYLES, has_graphic_styles, styled_objects};
pub(super) use styles::{PANEL_EMPTY, PANEL_STYLES, STYLE_LINKS, same_appearance};
pub(super) use images::PAYLOAD;
#[cfg(test)]
+264 -43
View File
@@ -1,17 +1,18 @@
//! Art styles: objects with live effects a plain path can't hold, written as their drawn look, the
//! object itself on `%_` lines and `1 (style) XW`, and the styles they name in
//! `%AI9_BeginArtStyles` (see [`crate::ai_editing`] for the form). Each style is read back before it
//! is written ([`crate::import::read_art_style`]: the `.ai` reader's style parser and its reading of
//! a graphic style, with the object's own gradient): one that reads back differently is not
//! written, and the object is written as its drawn look alone.
//! Art styles: objects whose appearance a plain path can't hold, written as their drawn look, the
//! object itself on `%_` lines and `1 (style) XW`; the styles they name and the Graphic Styles
//! panel's styles in `%AI9_BeginArtStyles`, and the panel's order in `%AI9_BeginArtStyleList` (see
//! [`crate::ai_editing`] for the form). Each style is read back before it is written
//! ([`crate::import::read_art_style`]: the `.ai` reader's style parser and its reading of a graphic
//! style, with the object's own gradient): one that reads back differently is not written, and the
//! object is written as its drawn look alone.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::fmt::Write;
use std::sync::Arc;
use serde_json::Value;
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Knockout, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, GraphicStyle, Knockout, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{BezPath, FillRule, Rect, Shape};
use super::{Writer, Xy, blend_code, compound_path, hide};
@@ -19,33 +20,35 @@ use crate::ai_ops;
use crate::ps;
pub(in crate::ai_editing) const LARGE_CANVAS_STYLES: &str = "on a large canvas, no art styles or graphic styles are written: objects with live effects are written with their geometry effects applied to the artwork, where they can no longer be edited, and their other effects are left out of the editing data for other apps";
pub(in crate::ai_editing) const GRAPHIC_STYLES: &str = "graphic styles and links to them are left out of the editing data for other apps";
pub(in crate::ai_editing) const PANEL_STYLES: &str = "graphic styles with gradient or pattern fills, with fills, strokes or effects the editing data can't hold as a graphic style, with a name another style has, with names over 1,024 bytes, or past 4,096 styles, are left out of the editing data for other apps";
pub(in crate::ai_editing) const PANEL_EMPTY: &str =
"a Graphic Styles panel with no style written opens from the editing data with a new document's styles";
pub(in crate::ai_editing) const STYLE_LINKS: &str = "links to graphic styles are left out of the editing data for other apps on type, images, symbol instances, knockout shapes and other objects that aren't written with their graphic style";
/// Does `doc` have graphic styles other than a new document's, or an object in its layers, opacity
/// masks or symbols linked to a graphic style?
pub(in crate::ai_editing) fn has_graphic_styles(doc: &Document) -> bool {
fn linked(n: &Node) -> bool {
n.extras().graphic_style.is_some()
|| n.extras().mask.as_ref().is_some_and(|m| linked(&m.art))
|| n.children().is_some_and(|c| c.iter().any(|m| linked(m)))
/// How many objects of `doc`'s layers, opacity masks and symbols are linked to a graphic style.
pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize {
fn count(n: &Node) -> usize {
let own = usize::from(n.extras().graphic_style.is_some());
let mask = n.extras().mask.as_ref().map_or(0, |m| count(&m.art));
own + mask + n.children().map_or(0, |c| c.iter().map(|m| count(m)).sum())
}
doc.graphic_styles != Document::new(1.0, 1.0).graphic_styles || doc.layers.iter().any(|l| linked(l)) || doc.symbols.iter().any(|s| linked(&s.art))
doc.layers.iter().map(|l| count(l)).sum::<usize>() + doc.symbols.iter().map(|s| count(&s.art)).sum::<usize>()
}
/// The objects of `doc`'s layers that may be written with an art style ([`Writer::styled_object`]),
/// by id: paths and compound paths with points and groups, with live effects, without Opacity &
/// Mask Define Knockout Shape (the reader takes an object's transparency from its look, which
/// doesn't carry it). A group's members are paths, compound paths or such groups, none with effects
/// or anything baking changes.
/// by id: paths and compound paths with points and groups, with live effects or linked to a graphic
/// style, without Opacity & Mask Define Knockout Shape (the reader takes an object's transparency
/// from its look, which doesn't carry it). A group's members are paths, compound paths or such
/// groups, none with effects or anything baking changes.
pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> HashMap<NodeId, Arc<Node>> {
fn walk(nodes: &[Arc<Node>], out: &mut HashMap<NodeId, Arc<Node>>) {
for n in nodes {
if candidate(n) {
out.insert(n.id, n.clone());
continue;
}
// Baking a container with effects of its own changes its members, which keep their ids:
// they are written as baking left them.
// A group's members are written with a style of their own only when the group is written
// without one. Baking a container with effects of its own changes its members, which
// keep their ids: they are written as baking left them.
if let NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } = &n.kind
&& !bakes_members(n)
{
@@ -69,7 +72,8 @@ fn bakes_members(n: &Node) -> bool {
/// Whether `n` may be written with an art style ([`styled_objects`]).
fn candidate(n: &Node) -> bool {
if n.appearance.effects.is_empty() || n.knockout_shape {
let styled = !n.appearance.effects.is_empty() || n.extras().graphic_style.is_some();
if !styled || n.knockout_shape {
return false;
}
match &n.kind {
@@ -96,25 +100,39 @@ fn plain_member(n: &Node) -> bool {
}
}
/// The art styles objects name, each with its text after its name (`/ActiveStyle :` to `/Def ;`).
/// The art styles written: the Graphic Styles panel's (listed, in its order) and those objects
/// name, each with its text after its name (`/ActiveStyle :` to `/Def ;`).
#[derive(Default)]
pub(in crate::ai_editing) struct Styles {
/// Every style, in the order written: its name and text.
known: Vec<(String, String)>,
/// The name of each style by its text.
/// The panel's styles written, by the graphic style's id.
panel: HashMap<u32, String>,
/// The panel's styles written, in its order.
listed: Vec<String>,
/// The names styles have, and the name of each object style by its text.
names: HashSet<String>,
by_text: HashMap<String, String>,
next_anon: u64,
/// How many objects were written with the graphic style they are linked to.
pub(super) links_written: usize,
}
impl Styles {
/// The name of an object style of `text`: a style with the same text already written, else a
/// new one named `Anon` and a number.
/// new one named `Anon` and a number no other style has.
fn object_style(&mut self, text: String) -> String {
if let Some(name) = self.by_text.get(&text) {
return name.clone();
}
self.next_anon += 1;
let name = format!("Anon {}", self.next_anon);
let name = loop {
self.next_anon += 1;
let name = format!("Anon {}", self.next_anon);
if !self.names.contains(&name) {
break name;
}
};
self.names.insert(name.clone());
self.by_text.insert(text.clone(), name.clone());
self.known.push((name.clone(), text));
name
@@ -286,16 +304,30 @@ impl Writer<'_> {
(!line.is_empty()).then_some(line)
}
/// An appearance item's basic filter. `None` for an item with transparency or effects of its
/// own, which the reader doesn't read on an object.
/// An appearance item's filter: its basic filter, or a chain of it, its effects and its
/// transparency.
fn item_filter(&mut self, s: &mut String, item: &AppearanceItem, on: Option<(&BezPath, FillRule)>) -> Option<()> {
if item.opacity() < 1.0 || item.blend() != BlendMode::Normal || !item.effects().is_empty() {
return None;
}
let plain = item.opacity() >= 1.0 && item.blend() == BlendMode::Normal && item.effects().is_empty();
let mut basic_text = String::new();
// In a chain, the chain holds the item's visibility.
let visible = item.visible() || !plain;
match item {
AppearanceItem::Fill(f) => self.fill_filter(s, f, item.visible(), on),
AppearanceItem::Stroke(st) => self.stroke_filter(s, st, item.visible()),
AppearanceItem::Fill(f) => self.fill_filter(&mut basic_text, f, visible, on)?,
AppearanceItem::Stroke(st) => self.stroke_filter(&mut basic_text, st, visible)?,
}
if plain {
s.push_str(&basic_text);
return Some(());
}
let _ = write!(s, "/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n{} /Visible ,\n", u8::from(item.visible()));
s.push_str(&basic_text);
for e in item.effects() {
s.push_str(&self.effect_filter(e)?);
}
let opacity = if item.opacity().is_finite() { item.opacity().clamp(0.0, 1.0) } else { 1.0 };
blend_filter(s, Xy { blend: item.blend(), opacity, ..Xy::OPAQUE });
s.push_str(";\n /Part ,\n");
Some(())
}
/// The text of a style of appearance `ap` and transparency `xy` (after its name): the chain
@@ -337,7 +369,13 @@ impl Writer<'_> {
let p = vectorcraft_effects::merged_params(&e.id, &e.params);
let mut d = Entries::default();
let id = e.id.as_str();
let name = if id.starts_with("stylize.") { self.stylize_entries(id, &p, &mut d)? } else { Self::path_entries(id, &p, &mut d)? };
let name = if id.starts_with("stylize.") {
self.stylize_entries(id, &p, &mut d)?
} else if id.starts_with("warp.") || id.starts_with("distort.") || id.starts_with("blur.") {
Self::distort_entries(id, &p, &mut d)?
} else {
Self::path_entries(id, &p, &mut d)?
};
let mut s = String::new();
basic(&mut s, name, true, e.visible);
s.push_str("/Dictionary : /NotRecorded ,\n");
@@ -460,9 +498,140 @@ impl Writer<'_> {
})
}
/// The style object `orig` (`n` before baking) is written with; `None` when the reader wouldn't
/// read the object as it is with it. A gradient fill is placed on `n`'s outline, as the object
/// draws it. A style that isn't written leaves no gradient definition or warning behind.
/// A Warp, Distort & Transform or Gaussian Blur effect's entries ([`Self::effect_filter`]) and
/// its filter's name.
fn distort_entries(id: &str, p: &Value, d: &mut Entries) -> Option<&'static str> {
Some(match id {
id if id.starts_with("warp.") => {
let style = crate::import::warp_number(id.strip_prefix("warp.")?)?;
d.int("DeformStyle", style);
d.real("DeformValue", num(p, "bend")? / 100.0);
d.real("DeformHoriz", num(p, "horizontal")? / 100.0);
d.real("DeformVert", num(p, "vertical")? / 100.0);
d.bool("Rotate", word(p, "orientation").as_deref() == Some("vertical"));
"Adobe Deform" // brand-ok: the filter's name in the files
}
"distort.transform" => {
d.real("scaleH_Factor", num(p, "scaleH")? / 100.0);
d.real("scaleV_Factor", num(p, "scaleV")? / 100.0);
d.real("moveH_Pts", num(p, "moveH")?);
// Art space has y up.
d.real("moveV_Pts", 0.0 - num(p, "moveV")?);
d.real("rotate_Radians", num(p, "rotate")?.to_radians());
d.int("numCopies", num(p, "copies")? as i64);
d.int("pinPoint", num(p, "reference")? as i64);
d.bool("reflectX", flag(p, "reflectX")?);
d.bool("reflectY", flag(p, "reflectY")?);
d.bool("transformObjects", flag(p, "objects")?);
d.bool("scaleLines", flag(p, "strokes")?);
d.bool("transformPatterns", flag(p, "patterns")?);
d.bool("randomize", false);
"Adobe Transform" // brand-ok: the filter's name in the files
}
"distort.freeDistort" => {
let corners = p.get("corners")?.as_array()?;
let corner = |i: usize| -> Option<(f64, f64)> {
let c = corners.get(i)?.as_array()?;
Some((c.first()?.as_f64()?, c.get(1)?.as_f64()?))
};
// The source frame is the unit square (y up); destination corners 0, 1, 3, 2 are
// the catalog's top left, top right, bottom right, bottom left (y down).
for (i, (h, v)) in [(0.0, 1.0), (1.0, 1.0), (0.0, 0.0), (1.0, 0.0)].into_iter().enumerate() {
d.real(&format!("src{i}h"), h);
d.real(&format!("src{i}v"), v);
}
for (i, from) in [0, 1, 3, 2].into_iter().enumerate() {
let (x, y) = corner(from)?;
d.real(&format!("dst{i}h"), x);
d.real(&format!("dst{i}v"), 1.0 - y);
}
"Adobe Free Distort" // brand-ok: the filter's name in the files
}
"distort.puckerBloat" => {
d.real("d_factor", num(p, "amount")?);
"Adobe Punk and Bloat" // brand-ok: the filter's name in the files
}
"distort.twist" => {
d.real("angle", num(p, "angle")?);
"Adobe Twirl" // brand-ok: the filter's name in the files
}
"distort.zigZag" => {
let relative = flag(p, "relative")?;
d.real("absoluteness", if relative { 0.0 } else { 1.0 });
d.real(if relative { "relAmount" } else { "amount" }, num(p, "size")?);
d.real("ridges", num(p, "ridges")?);
d.real("roundness", if word(p, "points").as_deref() == Some("smooth") { 1.0 } else { 0.0 });
"Adobe Zigzag" // brand-ok: the filter's name in the files
}
"blur.gaussian" => {
d.real("blur", num(p, "radius")?);
"Adobe PSL Gaussian Blur" // brand-ok: the filter's name in the files
}
_ => return None,
})
}
/// The Graphic Styles panel's styles that the reader reads as they are: each checked, and
/// the others left out with a warning. Their names are taken before the art's styles are.
pub(in crate::ai_editing) fn panel_styles(&mut self) {
let doc = self.doc;
// A new document's styles are what the reader gives a file without a style list; they are
// still written when an object is linked to one of them, so that it keeps its link.
let linked = self.styled.values().any(|n| n.extras().graphic_style.is_some());
if !linked && doc.graphic_styles == Document::new(1.0, 1.0).graphic_styles {
return;
}
for g in doc.graphic_styles.iter() {
self.styles.names.insert(g.name.clone());
}
let mut readable: Vec<(&GraphicStyle, String)> = vec![];
let mut seen: HashSet<&str> = HashSet::new();
for g in doc.graphic_styles.iter() {
if readable.len() >= crate::import::MAX_LISTED || g.name.len() > crate::import::MAX_LISTED_NAME || seen.contains(g.name.as_str()) {
self.warn(PANEL_STYLES);
continue;
}
let xy = Xy { blend: g.blend, opacity: g.opacity.clamp(0.0, 1.0), isolate: g.isolate, knockout: g.knockout, shape: false };
let text = self.style_text(&g.appearance, xy, None).filter(|t| panel_reads(g, t));
let Some(text) = text else {
self.warn(PANEL_STYLES);
continue;
};
seen.insert(g.name.as_str());
readable.push((g, text));
}
// The list as the reader reads it: names too long, and styles past its count, aren't.
let list: String = readable.iter().map(|(g, _)| ps::bytes(g.name.as_bytes()) + "\n").collect();
let listed: HashSet<String> = crate::import::read_style_list(list.as_bytes()).into_iter().collect();
for (g, text) in readable {
if !listed.contains(&g.name) {
self.warn(PANEL_STYLES);
continue;
}
if g.id != 0 {
self.styles.panel.insert(g.id, g.name.clone());
}
self.styles.listed.push(g.name.clone());
self.styles.known.push((g.name.clone(), text));
}
// The reader gives a file without a style list a new document's styles.
if self.styles.listed.is_empty() {
self.warn(PANEL_EMPTY);
}
}
/// Warn when fewer objects were written with their graphic style than `links` are linked to one.
pub(in crate::ai_editing) fn lost_links(&mut self, links: usize) {
if self.styles.links_written < links {
self.warn(STYLE_LINKS);
}
}
/// The style object `orig` (`n` before baking) is written with: the graphic style it is linked
/// to when it has that style's appearance and transparency and the panel's style is written,
/// else a style of its own; `None` when the reader wouldn't read the object as it is with
/// either. A gradient fill is placed on `n`'s outline, as the object draws it. A style of its own
/// that isn't written leaves no gradient definition or warning behind.
pub(super) fn object_style(&mut self, n: &Node, orig: &Node) -> Option<String> {
if let NodeKind::Group { children, .. } = &orig.kind
&& bakes_members(orig)
@@ -470,6 +639,21 @@ impl Writer<'_> {
{
return None;
}
let doc = self.doc;
if let Some(id) = orig.extras().graphic_style
&& let Some(name) = self.styles.panel.get(&id).cloned()
&& let Some(g) = doc.graphic_style_by_id(id)
&& g.transparency_matches(orig)
&& g.appearance_on(orig).approx_eq(&orig.appearance)
&& let Some(text) = self.styles.known.iter().find(|(n, _)| *n == name).map(|(_, t)| t.clone())
&& object_reads(orig, &text)
{
self.styles.links_written += 1;
return Some(name);
}
if orig.appearance.effects.is_empty() || !own_style_holds(&orig.appearance) {
return None;
}
let graded = orig.appearance.items.iter().any(|i| matches!(i.paint(), Paint::Gradient(_)));
let saved = (graded.then(|| self.gradient_mark()), self.warnings.len());
let path = drawn_outline(n, orig);
@@ -582,6 +766,20 @@ impl Writer<'_> {
}
s.push_str("%AI9_EndArtStyles\n%AI5_End_NonPrinting--\n");
}
/// The panel's styles written, in its order, in a non-printing block:
/// `%AI9_BeginArtStyleList`, a name on each line, `%AI9_EndArtStyleList`.
pub(in crate::ai_editing) fn art_style_list(&self, s: &mut String) {
if self.styles.listed.is_empty() {
return;
}
s.push_str("%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyleList\n");
for name in &self.styles.listed {
s.push_str(&ps::bytes(name.as_bytes()));
s.push('\n');
}
s.push_str("%AI9_EndArtStyleList\n%AI5_End_NonPrinting--\n");
}
}
/// The outline a path's or compound path's style places a gradient fill on.
@@ -608,15 +806,38 @@ fn object_rule(n: &Node) -> FillRule {
}
}
/// Can appearance `ap` be written as a style of an object's own: fills and strokes that paint without
/// transparency or effects of their own, and no Warp, Distort & Transform or Blur effect? Those
/// are written only in the Graphic Styles panel's styles.
fn own_style_holds(ap: &Appearance) -> bool {
let plain = |i: &AppearanceItem| i.opacity() >= 1.0 && i.blend() == BlendMode::Normal && i.effects().is_empty();
let panel_only = |e: &Effect| ["warp.", "distort.", "blur."].iter().any(|p| e.id.starts_with(p));
ap.items.iter().filter(|i| !i.paint().is_none()).all(plain) && !ap.effects.iter().any(panel_only)
}
/// Does the reader read graphic style `g` from style text `text` as it is?
fn panel_reads(g: &GraphicStyle, text: &str) -> bool {
let section = known_style("x", text);
let Some(read) = crate::import::read_art_style(section.as_bytes(), "x", None) else { return false };
// The opacity within the written number's precision.
read.transparency.is_some_and(|t| (t.0 - g.opacity).abs() <= 1e-4 && (t.1, t.2, t.3) == (g.blend, g.isolate, g.knockout))
&& same_appearance(&read.appearance, &g.appearance)
}
/// Does the reader read object `n` written with style text `text` as it is: a path or compound
/// path with a style it reads on any object, filled by the non-zero rule when the style fills it,
/// or a group with no fills or strokes of its own?
/// or a group with no fills or strokes of its own, with effects that move the outline only where the
/// reader reads them ([`crate::import::style_moves_with`])?
fn object_reads(n: &Node, text: &str) -> bool {
let section = known_style("x", text);
let own = n.appearance.fill().map(|f| &f.paint).filter(|p| matches!(p, Paint::Gradient(_)));
let Some(mut read) = crate::import::read_art_style(section.as_bytes(), "x", own) else { return false };
let group = matches!(n.kind, NodeKind::Group { .. });
if !read.simple || (group && !read.appearance.items.is_empty()) || (!group && read.pathfinder) {
if !read.simple
|| (group && !read.appearance.items.is_empty())
|| (!group && read.pathfinder)
|| !crate::import::style_moves_with(section.as_bytes(), "x", n)
{
return false;
}
if !group {
@@ -203,6 +203,8 @@ impl Writer<'_> {
if sym.dynamic || doc.symbol_export_type(name) != SymbolExportType::default() || doc.symbol_nine_slice(name).is_some() {
self.warn(SYMBOL_OPTIONS);
}
// The art's effects are its own, not those of a drawn look that first places it.
let in_look = std::mem::take(&mut self.in_look);
self.defining_symbols.push(name.to_string());
let mut used = vec![];
vectorcraft_doc::swatches::walk_with_masks(&sym.art, &mut |m| {
@@ -216,6 +218,7 @@ impl Writer<'_> {
if self.symbol_defs.len() + self.defining_symbols.len() > MAX_SYMBOLS {
self.defining_symbols.pop();
self.warn(SYMBOL_COUNT);
self.in_look = in_look;
return false;
}
// The art is written in the symbol's space, from the state `0 A` and `0 Xw` set, from the
@@ -291,6 +294,7 @@ impl Writer<'_> {
(self.wrote_fixed, self.inner_scale),
) = saved;
self.doc = page_doc;
self.in_look = in_look;
let art = std::mem::replace(&mut self.body, art);
self.defining_symbols.pop();
let text = format!("%AI14_BeginSymbol\n{}\n0 A\n0 Xw\n0 Ae\n{art}%AI10_EndSymbol\n", ps::bytes(name.as_bytes()));
+3 -1
View File
@@ -73,7 +73,9 @@ use graphics::{GState, Out};
use interp::{Fault, Interp};
use obj::PsError;
pub(crate) use ai::{MAX_GRADIENT_STOPS, MAX_SYMBOLS, read_art_style, symbol_art_member};
pub(crate) use ai::{
MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, read_art_style, read_style_list, style_moves_with, symbol_art_member, warp_number,
};
pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES};
pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from};
pub(crate) use native::{MAX_TEXT, scale_document};
+6 -3
View File
@@ -43,12 +43,15 @@ pub(crate) use paint::MAX_STOPS as MAX_GRADIENT_STOPS;
#[cfg(test)]
pub(crate) use read::with_image_limits;
pub use read::{
BindingRef, Brushes, PaletteSwatches, PlacedPage, Structure, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette, read,
read_beside, variables,
BindingRef, Brushes, PaletteSwatches, PlacedPage, Structure, Symbols, active_artboard, artboards, bindings_note, brushes, graphic_styles,
palette, read, read_beside, symbols, variables,
};
pub(crate) use read::{
MAX_SYMBOLS, join_and, read_art_style, read_over_page, read_style_list, slot_frame, slot_of, style_moves_with, symbol_art_member,
};
pub(crate) use read::{MAX_SYMBOLS, join_and, read_art_style, read_over_page, slot_frame, slot_of, symbol_art_member};
pub(crate) use read::is_look_slot;
pub(crate) use style::{MAX_LISTED, MAX_LISTED_NAME, warp_number};
#[cfg(test)]
mod tests;
+3 -2
View File
@@ -142,10 +142,11 @@ mod types;
pub(crate) use art::join_and;
use art::preview_png;
pub use document::{
BindingRef, Brushes, PaletteSwatches, VariableRead, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette, variables,
BindingRef, Brushes, PaletteSwatches, Symbols, VariableRead, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette,
symbols, variables,
};
pub(crate) use document::{slot_frame, slot_of};
pub(crate) use ops::read_art_style;
pub(crate) use ops::{read_art_style, read_style_list, style_moves_with};
#[cfg(test)]
pub(crate) use raster::with_image_limits;
pub use types::PlacedPage;
+19 -3
View File
@@ -503,7 +503,15 @@ impl<'a> Reader<'a> {
let Some(tile) = head.tile.filter(|t| whole && sane(*t) && t.width() > 0.0 && t.height() > 0.0 && !frame.children.is_empty()) else {
return;
};
let art: Vec<Arc<Node>> = frame.children.iter().map(|n| Arc::new(self.doc.reid(n))).collect();
let art: Vec<Arc<Node>> = frame
.children
.iter()
.map(|n| {
let mut kept = self.doc.reid(n);
self.reid_masks(&mut kept, 0);
Arc::new(kept)
})
.collect();
// The objects of the art linked to graphic styles, by their ids in the document.
if !sub.style_links.is_empty() {
let mut ids = HashMap::new();
@@ -671,7 +679,7 @@ impl<'a> Reader<'a> {
named
}
/// Give the art of the opacity masks in `n` (a copy of a symbol's art or of a clipping path with ids of this
/// Give the art of the opacity masks in `n` (a copy of a symbol's, a pattern's or a brush's art, or of a clipping path, with ids of this
/// document, [`vectorcraft_doc::Document::reid`], which leaves masks as they are) ids of this
/// document too.
pub(super) fn reid_masks(&mut self, n: &mut Node, depth: usize) {
@@ -783,7 +791,15 @@ impl<'a> Reader<'a> {
&& sub.doc.images.is_empty();
let Some(frame) = sub.frames.pop().filter(|f| whole && !f.children.is_empty()) else { return };
self.brush_art_looks.insert(name.clone(), sub.drawn_looks);
let mut art: Vec<Arc<Node>> = frame.children.iter().map(|n| Arc::new(self.doc.reid(n))).collect();
let mut art: Vec<Arc<Node>> = frame
.children
.iter()
.map(|n| {
let mut kept = self.doc.reid(n);
self.reid_masks(&mut kept, 0);
Arc::new(kept)
})
.collect();
let node = match art.len() {
1 => art.pop().map(Arc::unwrap_or_clone),
_ => Some(Node::group(self.doc.alloc_id(), art)),
+67
View File
@@ -405,6 +405,73 @@ pub fn graphic_styles(data: &[u8], large_canvas: bool) -> (Vec<GraphicStyle>, f6
(r.graphic_styles().0, r.canvas_scale)
}
/// The symbols of a `.ai` file's editing data, as [`symbols`] reads them.
pub struct Symbols {
/// The symbols in the Symbols panel's order, each with its Symbol Options › Registration.
pub symbols: Vec<(Symbol, vectorcraft_doc::SymbolRegistration)>,
/// The image blobs the symbols' art shows, by key.
pub images: std::collections::BTreeMap<String, ImageBlob>,
/// The global and spot colors the symbols' art is linked to, as swatches, as [`read`] adds
/// them to a document read from the layers.
pub swatches: Vec<Swatch>,
}
/// The symbols of the editing data `data`, for a file whose layers aren't read (a symbol library
/// has none), as [`read`] makes them: in the symbol list's order, without those that hold an
/// instance of a symbol the data doesn't define, and with each object of their art that is linked
/// to a listed graphic style linked to it by the id [`graphic_styles`] gives that style. The
/// symbols are those defined before reading stops and before the first layer; `None` when there
/// are none.
pub fn symbols(data: &[u8]) -> Option<Symbols> {
crate::import::ai::find(data, b"BeginSymbol")?;
let mut r = Reader::with_names(data);
// The symbols come before the art, so an error in the art leaves them read.
let _ = r.run();
r.check_symbols();
r.order_symbols();
if r.doc.symbols.is_empty() {
return None;
}
let (styles, ids) = r.graphic_styles();
let links: HashMap<NodeId, u32> = r.style_links.iter().filter_map(|(node, name)| Some((*node, *ids.get(name)?))).collect();
if !links.is_empty() {
for symbol in &mut r.doc.symbols {
link_styles(std::slice::from_mut(&mut symbol.art), &links, &styles, 0);
}
}
let mut shown = Document::new(1.0, 1.0);
shown.symbols = std::mem::take(&mut r.doc.symbols);
let mut images = std::collections::BTreeMap::new();
shown.visit_images(|_, im| {
if let Some(blob) = r.doc.images.get(&im.key) {
images.insert(im.key.clone(), blob.clone());
}
});
// The colors the art is linked to that the file defines, once each.
let mut linked = std::collections::HashSet::new();
for s in &mut shown.symbols {
vectorcraft_doc::swatches::map_node_solid_paints(Arc::make_mut(&mut s.art), &mut |_, link, _| {
linked.extend(link.clone());
false
});
}
let mut swatches: Vec<Swatch> = vec![];
let colors = r.spots.iter().chain(&r.stop_spots).map(|n| (n, n.spot)).chain(r.custom_colors.iter().map(|n| (n, true)));
for (n, spot) in colors {
if linked.remove(&n.name) {
swatches.push(Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot });
}
}
let symbols = std::mem::take(&mut shown.symbols)
.into_iter()
.map(|s| {
let reg = r.doc.symbol_registration(&s.name);
(s, reg)
})
.collect();
Some(Symbols { symbols, images, swatches })
}
/// [`artboards`] as far as reading `data` from its start gets: those read before an error, and
/// those of a `/Document` still open when it stopped (its art can be what stopped it).
fn artboards_read(data: &[u8]) -> (Vec<(String, Rect)>, Option<usize>) {
+15 -1
View File
@@ -1182,6 +1182,13 @@ pub(crate) struct StyleRead {
pub simple: bool,
/// It has Effect › Pathfinder effects, which only a group with no fills or strokes is read with.
pub pathfinder: bool,
/// The opacity, blending mode, isolation and knockout its blend filter gives.
pub transparency: Option<(f32, BlendMode, bool, Knockout)>,
}
/// The names a style list section gives graphic styles, as the reader reads them.
pub(crate) fn read_style_list(section: &[u8]) -> Vec<String> {
crate::import::ai::style::art_style_list(section).iter().map(|raw| obj::text(raw)).filter(|name| !name.is_empty()).collect()
}
/// Art style `name` of `section` as the reader reads it ([`StyleRead`]): [`appearance_from_art_style`],
@@ -1200,7 +1207,14 @@ pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&P
}
}
let appearance = appearance_from_art_style(&st)?;
Some(StyleRead { appearance, simple: st.simple, pathfinder: st.pathfinder })
Some(StyleRead { appearance, simple: st.simple, pathfinder: st.pathfinder, transparency: st.transparency })
}
/// Is art style `name` of `section` read on `object` as far as an effect that moves the outline
/// goes: the style has none, or `object` is one [`moves_with`] reads it on, as [`styled`] checks?
/// `false` when the section doesn't define it.
pub(crate) fn style_moves_with(section: &[u8], name: &str, object: &Node) -> bool {
crate::import::ai::style::art_styles(section).remove(name).is_some_and(|st| !st.moves_outline() || moves_with(object, &st))
}
/// A style's stroke as an appearance item (`None` for a fill).
+8 -2
View File
@@ -122,6 +122,12 @@ pub(super) fn warp_style(n: i64) -> Option<&'static str> {
id.strip_prefix("warp.")
}
/// The number `DeformStyle` gives warp style `style` (`arc`, `arcLower`, …), from 1.
pub(crate) fn warp_number(style: &str) -> Option<i64> {
let at = WARPS.iter().position(|id| id.strip_prefix("warp.") == Some(style))?;
i64::try_from(at + 1).ok()
}
/// The Effect › Pathfinder effects by the `Command` number of their filter. `Command` 0 is Add
/// (https://community.adobe.com/questions-652/liveeffect-xml-effect-is-added-under-the-object-795137);
/// the numbers 1 to 11 are VectorCraft's own reader rules, learned from saved files. Trap (12) isn't
@@ -326,9 +332,9 @@ impl Default for Filter {
const MAX_FILTERS: usize = 4096;
/// Most entries read from a style list.
const MAX_LISTED: usize = 4096;
pub(crate) const MAX_LISTED: usize = 4096;
/// The longest style name read from a style list, in bytes.
const MAX_LISTED_NAME: usize = 1024;
pub(crate) const MAX_LISTED_NAME: usize = 1024;
/// The style list (`%AI9_BeginArtStyleList`, between its markers): one literal string per style,
/// the Graphic Styles panel's order. Each name is its string's bytes, escapes undone; other tokens,
+10
View File
@@ -1366,6 +1366,15 @@ fn convert_to_shape_settings_that_arent_read_keep_the_drawn_look() {
assert!(!art_styles(data.as_bytes())["Odd"].plain);
}
#[test]
fn warp_numbers_are_the_dialogs_order() {
for n in 1..=15 {
let style = warp_style(n).unwrap();
assert_eq!(warp_number(style), Some(n), "{style}");
}
assert_eq!((warp_style(0), warp_style(16), warp_number("arcs"), warp_number("")), (None, None, None, None));
}
/// A style as a writer checks it: the reader's appearance, with a gradient fill as the object's own,
/// and nothing for styles it doesn't read.
#[test]
@@ -1379,6 +1388,7 @@ fn a_style_is_read_as_a_writer_checks_it() {
let got = read(&data, Some(&own)).unwrap();
assert_eq!(got.appearance.items.len(), 1);
assert_eq!(got.appearance.items[0].paint(), &own);
assert_eq!(got.transparency.map(|t| (t.0, t.1)), Some((0.5, BlendMode::Multiply)));
assert!(got.simple && !got.pathfinder);
// A gradient that isn't the object's own, a pattern, another name, two gradients: nothing.
assert!(read(&data, None).is_none());
+104
View File
@@ -5974,6 +5974,37 @@ fn an_object_in_a_brushs_art_with_a_style_this_reads_comes_in_as_itself() {
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
}
/// The art of an opacity mask in a brush's art takes ids of the document, as the rest of the art
/// does: no object of the brush's art shares an id with an object of the layers.
#[test]
fn a_brushs_opacity_mask_art_takes_ids_of_the_document() {
fn ids(n: &Node, out: &mut Vec<vectorcraft_doc::NodeId>) {
out.push(n.id);
if let Some(m) = n.extras().mask.as_ref() {
ids(&m.art, out);
}
for c in n.children().into_iter().flatten() {
ids(c, out);
}
}
let square = rect_path(0.0, 0.0, 10.0, 10.0);
let mask_art: String = format!("1 g\n{square}f\n").lines().map(|l| format!("%_{l}\n")).collect();
let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{mask_art}%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_; (AI9_Mask) ,\n%_;\n");
let section = format!("%AI8_BeginBrushPattern\n(Dot art)\n0 A\n{RED}{square}f\n{mask}%AI8_EndBrushPattern\n");
let shapes: String = (0..12).map(|i| format!("{RED}{}f\n", rect_path(f64::from(i) * 20.0, 0.0, f64::from(i) * 20.0 + 10.0, 10.0))).collect();
let r = read_ok(&format!("{section}{}{}", plugin_object(SCATTER, "Spray", SPRAY), layer(&shapes)));
let lib = vectorcraft_brush::library(&r.doc);
let spray = lib.iter().find(|b| b.name == "Spray").unwrap_or_else(|| panic!("{:?}", r.warnings));
let art = spray.art();
assert!(art.iter().any(|n| n.extras().mask.is_some()), "the mask is read: {art:#?}");
let mut all = Vec::new();
for n in r.doc.layers.iter().map(|l| &**l).chain(art) {
ids(n, &mut all);
}
let unique: std::collections::HashSet<_> = all.iter().copied().collect();
assert_eq!(unique.len(), all.len(), "{all:?}");
}
/// An art styles section inside a symbol's, a pattern's or a brush's art is skipped: the file's own
/// art styles still style the layers' objects read after the definition.
#[test]
@@ -6184,6 +6215,50 @@ fn an_object_in_a_symbols_art_with_a_listed_style_is_linked() {
assert_eq!(art.extras().graphic_style, Some(1), "{art:#?}");
}
/// Editing data with symbols and no layers (a symbol library) gives its symbols: in the symbol
/// list's order with the unlisted one after them, with their registration points and the images
/// their art shows, without the one holding an instance of a symbol the data doesn't define, and
/// with the object linked to a listed graphic style linked to it by its id.
#[test]
fn a_symbol_library_without_layers_gives_its_symbols() {
let styles = styles_and_list(&[fill_style("Blue", "0 0 0 0 0 0 1 Xa\n")], &["Blue"]);
// The template box's center is (100.5, 50.5): art space's origin is at canvas (8091, 8242).
let nested = instance("Dot", [1.0, 0.0, 0.0, -1.0, -8081.0, 8237.0]);
let lost = instance("Ghost", [1.0, 0.0, 0.0, -1.0, -8081.0, 8237.0]);
let m = "[ 5 0 0 5 -8096 8244.5 ]";
let raster = format!(
"%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceRGB XN\n{m} 2 1 0 Xh\n{m} 0 0 2 1 2 1 8 3 0 0 1 0 4 4 0 0\n\
%%BeginData: 6\rXI\nABCDEF%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\n"
);
let defs = symbols(
&(symbol("Dot", &dot(), 1)
+ &symbol("Pic", &raster, 5)
+ &symbol("Pair", &nested, 5)
+ &symbol("Tile", &styled_square("Blue"), 5)
+ &symbol("Lost", &lost, 5)),
);
let list = "%AI24_BeginSymbolList\n(Tile)\n(Pair)\n(Lost)\n(Dot)\n%AI24_EndSymbolList\n";
let body = format!("%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n{styles}{defs}{list}");
let read = super::symbols(stream(&body).as_bytes()).expect("the library's symbols");
let names: Vec<&str> = read.symbols.iter().map(|(s, _)| s.name.as_str()).collect();
assert_eq!(names, ["Tile", "Pair", "Dot", "Pic"]);
let registrations: Vec<u8> = read.symbols.iter().map(|(_, r)| r.0).collect();
let center = vectorcraft_doc::SymbolRegistration::CENTER.0;
assert_eq!(registrations, [center, center, 0, center]);
let Some(((tile, _), (pair, _))) = read.symbols.first().zip(read.symbols.get(1)) else { panic!("two symbols") };
assert_eq!(tile.art.extras().graphic_style, Some(1), "{:#?}", tile.art);
// Dot sits at (10, 5) in Pair's art.
let mut doc = Document::new(1.0, 1.0);
doc.symbols = read.symbols.iter().map(|(s, _)| s.clone()).collect();
assert_eq!(pair.art.visual_bounds_with(&doc.symbol_boxes()), Some(Rect::new(8.0, 3.0, 12.0, 7.0)));
let keys: Vec<String> =
doc.symbols.iter().filter_map(|s| if let NodeKind::Image(im) = &s.art.kind { Some(im.key.clone()) } else { None }).collect();
assert_eq!(keys.len(), 1);
assert_eq!(read.images.keys().cloned().collect::<Vec<_>>(), keys);
// Without symbols there are none to give.
assert!(super::symbols(stream(&styles).as_bytes()).is_none());
}
#[test]
fn an_object_in_a_patterns_art_with_a_listed_style_is_linked() {
let defs = styles_and_list(&[fill_style("Blue", "0 0 0 0 0 0 1 Xa\n")], &["Blue"]);
@@ -6196,6 +6271,35 @@ fn an_object_in_a_patterns_art_with_a_listed_style_is_linked() {
assert_eq!(square.extras().graphic_style, Some(1), "{square:#?}");
}
/// The art of an opacity mask in a pattern's art takes ids of the document, as the rest of the art
/// does: no object of the pattern's art shares an id with an object of the layers.
#[test]
fn a_patterns_opacity_mask_art_takes_ids_of_the_document() {
fn ids(n: &Node, out: &mut Vec<vectorcraft_doc::NodeId>) {
out.push(n.id);
if let Some(m) = n.extras().mask.as_ref() {
ids(&m.art, out);
}
for c in n.children().into_iter().flatten() {
ids(c, out);
}
}
let square = rect_path(0.0, 0.0, 10.0, 10.0);
let mask_art: String = format!("1 g\n{square}f\n").lines().map(|l| format!("%_{l}\n")).collect();
let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{mask_art}%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_; (AI9_Mask) ,\n%_;\n");
let pattern = format!("%AI3_BeginPattern: (P)\n(P) 0 0 10 10\n0 A\n0 Xw\n{RED}{square}f\n{mask}E\n%AI3_EndPattern\n");
let shapes: String = (0..12).map(|i| format!("{RED}{}f\n", rect_path(f64::from(i) * 20.0, 0.0, f64::from(i) * 20.0 + 10.0, 10.0))).collect();
let r = read_ok(&format!("{pattern}{}", layer(&shapes)));
let def = r.doc.pattern("P").unwrap_or_else(|| panic!("{:?}", r.warnings));
assert!(def.art.iter().any(|n| n.extras().mask.is_some()), "the mask is read: {:#?}", def.art);
let mut all = Vec::new();
for n in r.doc.layers.iter().chain(&def.art) {
ids(n, &mut all);
}
let unique: std::collections::HashSet<_> = all.iter().copied().collect();
assert_eq!(unique.len(), all.len(), "{all:?}");
}
#[test]
fn an_object_in_a_version_7_patterns_tile_with_a_listed_style_is_linked() {
let defs = styles_and_list(&[fill_style("Blue", "0 0 0 0 0 0 1 Xa\n")], &["Blue"]);
+1 -1
View File
@@ -1192,7 +1192,7 @@ fn keep_differing(
}
/// Copy page drawing IDs, including mask art outside the ordinary child tree.
fn reid_page_art(doc: &mut Document, source: &Node) -> Node {
pub(super) fn reid_page_art(doc: &mut Document, source: &Node) -> Node {
let mut copy = doc.reid(source);
if let Some(mask) = &mut copy.extras_mut().mask {
mask.art = Arc::new(reid_page_art(doc, &mask.art));
+83 -18
View File
@@ -1,4 +1,4 @@
use super::build::{PAGE_MOVE, Page, build, build_from, build_on_page, build_without_page};
use super::build::{PAGE_MOVE, Page, build, build_from, build_on_page, build_without_page, reid_page_art};
use super::slots::LAYERS_UNREAD;
use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect, Vec2, ai};
@@ -7,11 +7,11 @@ use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect
/// hidden ones too), groups, compound paths, clipping groups and artboards, with the text and the
/// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it
/// has one that can't be used, `visible` with a warning that says why, with the swatches of the
/// editing copy's palette ([`use_palette`]), its graphic styles ([`use_styles`]) and its variable
/// library ([`use_variables`]) when it could be read, and with its layer tree, empty,
/// below the page's layer when that could be read (#2033). A program in the legacy format without
/// layers (`illustrator`, see [`program_alone`]) is read with its art on one layer; when that
/// can't be used, the file is `visible` as it was.
/// editing copy's palette ([`use_palette`]), its graphic styles ([`use_styles`]), its symbols
/// ([`use_symbols`]) and its variable library ([`use_variables`]) when it could be read, and with
/// its layer tree, empty, below the page's layer when that could be read (#2033). A program in the
/// legacy format without layers (`illustrator`, see [`program_alone`]) is read with its art on one
/// layer; when that can't be used, the file is `visible` as it was.
pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illustrator: bool) -> Imported {
let Some(data) = editing_copy(ps) else {
let read = illustrator.then(|| one_layer(ps)).flatten().and_then(|p| build(&p, Some(&visible.document), Page::Art, false, trust, 1.0).ok());
@@ -38,9 +38,10 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illus
let mut v = visible;
if let Ok(data) = &data {
let have = v.document.patterns.len();
use_palette(data, &mut v.document);
let own_palette = use_palette(data, &mut v.document);
use_brushes(data, &mut v.document, have, &mut v.warnings);
use_styles(data, &mut v.document, true, 1.0, None);
use_symbols(data, &mut v.document, own_palette, &mut v.warnings);
use_variables(data, &mut v.document, &mut v.warnings);
}
if tree.is_empty() {
@@ -100,10 +101,11 @@ fn layer_tree(nodes: &[Arc<Node>]) -> Vec<Arc<Node>> {
/// notes, or a note saying why the layers weren't read when the editing data can't be decompressed
/// or its layers can't be read. The flag returned is `true` when the document is the one read from
/// the layers; else the document is `visible`, its pages moved by `place_pages` when the editing
/// data was decompressed but its layers can't be read, and its swatches, brushes, graphic styles and
/// variables those of the editing data's palette ([`use_palette`]), brushes ([`use_brushes`]), styles
/// ([`use_styles`]) and variable library ([`use_variables`]) when it was decompressed: a graphic
/// style library, which has no layers, opens with its styles, and editing data with no layers opens
/// data was decompressed but its layers can't be read, and its swatches, brushes, graphic styles,
/// symbols and variables those of the editing data's palette ([`use_palette`]), brushes
/// ([`use_brushes`]), styles ([`use_styles`]), symbols ([`use_symbols`]) and variable library
/// ([`use_variables`]) when it was decompressed: a graphic style library or a symbol library, which
/// has no layers, opens with its styles or its symbols, and editing data with no layers opens
/// on the page of its active artboard under the checks of `place_pages`. `outlined`: the PDF part's type was
/// read as outlines (a file whose type shows then comes in as its PDF part).
/// `trust`: layers that draw differently from the PDF part are used all the same, with a warning.
@@ -140,9 +142,10 @@ pub fn layered_ai_from(
visible.opened_artboard = active_page(active, &visible);
}
let have = visible.patterns.len();
use_palette(&data, &mut visible);
let own_palette = use_palette(&data, &mut visible);
use_brushes(&data, &mut visible, have, &mut warnings);
use_styles(&data, &mut visible, false, unit, Some(&boards));
use_symbols(&data, &mut visible, own_palette, &mut warnings);
use_variables(&data, &mut visible, &mut warnings);
return (visible, warnings, false);
}
@@ -152,10 +155,11 @@ pub fn layered_ai_from(
let (boards, active) = ai::artboards(&data);
place_pages(&boards, active, &mut visible, unit);
let have = visible.patterns.len();
use_palette(&data, &mut visible);
let own_palette = use_palette(&data, &mut visible);
use_brushes(&data, &mut visible, have, &mut warnings);
use_styles(&data, &mut visible, false, unit, Some(&boards));
let mut all = unread(warnings, why);
use_symbols(&data, &mut visible, own_palette, &mut all);
use_variables(&data, &mut visible, &mut all);
(visible, all, false)
};
@@ -246,14 +250,14 @@ fn use_variables(data: &[u8], doc: &mut Document, warnings: &mut Vec<String>) {
/// palette doesn't have follow the palette's swatches. The patterns that the editing data defines come
/// in for the palette's pattern swatches, with the images they show; one whose name a pattern of
/// the PDF part already has takes a free variant of it ([`ai::palette`]). Left as
/// it is when the palette is empty.
fn use_palette(data: &[u8], doc: &mut Document) {
let Some(ai::PaletteSwatches { swatches, groups, patterns, images }) = ai::palette(data, &doc.patterns) else { return };
/// it is when the palette is empty. Whether the palette's swatches came in.
fn use_palette(data: &[u8], doc: &mut Document) -> bool {
let Some(ai::PaletteSwatches { swatches, groups, patterns, images }) = ai::palette(data, &doc.patterns) else { return false };
for (key, blob) in images {
doc.images.entry(key).or_insert(blob);
}
for mut p in patterns {
p.art = p.art.iter().map(|n| Arc::new(doc.reid(n))).collect();
p.art = p.art.iter().map(|n| Arc::new(reid_page_art(doc, n))).collect();
doc.patterns.push(p);
}
let named = |name: &str| swatches.iter().chain(groups.iter().flat_map(|g| &g.swatches)).any(|s| s.name == name);
@@ -262,6 +266,7 @@ fn use_palette(data: &[u8], doc: &mut Document) {
let mut swatches = swatches;
swatches.extend(art);
(doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into());
true
}
/// `doc` (a file read as its page or PDF part, in place of its layers) with the brushes of its
@@ -280,7 +285,7 @@ fn use_brushes(data: &[u8], doc: &mut Document, have: usize, warnings: &mut Vec<
if doc.pattern(&p.name).is_some() {
continue;
}
p.art = p.art.iter().map(|n| Arc::new(doc.reid(n))).collect();
p.art = p.art.iter().map(|n| Arc::new(reid_page_art(doc, n))).collect();
doc.patterns.push(p);
}
vectorcraft_brush::store(doc, &brushes);
@@ -320,6 +325,66 @@ fn use_styles(data: &[u8], doc: &mut Document, large_canvas: bool, unit: f64, bo
doc.graphic_styles = styles.into();
}
/// `doc` (a file read as its page or PDF part, in place of its layers) with the symbols of its
/// editing data `data` ([`ai::symbols`]), their registration points, the images their art shows and
/// the global and spot colors it is linked to that `doc` has no such swatch for (a color whose name
/// another swatch has takes that swatch's place when it is a default swatch and not `own_palette`,
/// the swatches being those of the editing data's palette, and else comes in under a free variant
/// of the name, which the art then names, as does one whose name a color group has), as [`build`] gives a document read from the layers, and
/// the note about instances that can't be drawn added to `warnings`. An object of their art linked
/// to a graphic style keeps the link when [`use_styles`] gave `doc` the editing data's styles. The
/// symbols' art keeps the editing data's units, as in a document read from the layers: when
/// [`use_styles`] draws the styles' strokes and effects larger (a file in larger units), linked art
/// isn't scaled with them. Left as it is when `doc` has symbols already or the editing data has
/// none this reads.
fn use_symbols(data: &[u8], doc: &mut Document, own_palette: bool, warnings: &mut Vec<String>) {
if !doc.symbols.is_empty() {
return;
}
let Some(ai::Symbols { symbols, images, swatches }) = ai::symbols(data) else { return };
for (key, blob) in images {
doc.images.entry(key).or_insert(blob);
}
// A color whose name a swatch of another color has takes that swatch's place when it is a
// default swatch, and else a free variant of the name, as does one whose name a color group has.
let (defaults, groups) = vectorcraft_color::default_swatches(doc.color_mode.model());
let is_default =
|have: &vectorcraft_color::Swatch| !own_palette && defaults.iter().chain(groups.iter().flat_map(|g| &g.swatches)).any(|d| d == have);
let mut renamed = std::collections::HashMap::new();
for s in swatches {
match doc.swatch(&s.name) {
None if !doc.swatch_name_taken(&s.name) => doc.swatches.push(s),
Some(have) if have == &s || (s.name.starts_with('[') && s.name.ends_with(']')) => {}
Some(have) if is_default(have) => {
if let Some(default) = doc.swatch_mut(&s.name) {
*default = s;
}
}
_ => {
let name = doc.free_swatch_name(&s.name);
renamed.insert(s.name.clone(), name.clone());
doc.swatches.push(vectorcraft_color::Swatch { name, ..s });
}
}
}
let mut follow = |_: &mut vectorcraft_color::Color, link: &mut Option<String>, _: &mut f32| {
let Some(new) = link.as_ref().and_then(|l| renamed.get(l)) else { return false };
*link = Some(new.clone());
true
};
for (mut symbol, registration) in symbols {
let mut art = reid_page_art(doc, &symbol.art);
if !renamed.is_empty() {
vectorcraft_doc::swatches::map_node_solid_paints(&mut art, &mut follow);
}
symbol.art = Arc::new(art);
doc.set_symbol_registration(&symbol.name, registration);
doc.symbols.push(symbol);
}
// Symbols that hold instances of themselves can't be drawn: those instances go.
doc.limit_depth_noting(warnings);
}
/// How many points a unit of `boards` (the artboards of a file's editing data, in art space) is in
/// `doc` (the file read as its PDF part: a page per artboard): 1 when there are as many artboards as
/// pages, each the size of its page in points, or `unit` when `unit` is above 1 and each is the size
+255
View File
@@ -1437,6 +1437,37 @@ fn an_ai_file_without_layers_keeps_the_patterns_of_its_palette() {
assert!(keys.iter().all(|k| d.images.contains_key(k)), "{keys:?}");
}
/// The art of a pattern with an opacity mask that a palette swatch or a brush's art paints with,
/// in a file without layers, takes ids of the document, its mask's art included.
#[test]
fn a_pattern_of_a_file_without_layers_takes_ids_for_its_mask_art() {
let mask_art: String = symbol_square(0.0, 0.0, 4.0, 4.0).replace("0 1 1 0 1 0 0 Xa", "1 g").lines().map(|l| format!("%_{l}\n")).collect();
let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{mask_art}%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_; (AI9_Mask) ,\n%_;\n");
let hidden = |art: String| -> String { art.lines().map(|l| format!("%_{l}\n")).collect() };
let tile = format!("{}{mask}{}", hidden(symbol_square(0.0, 0.0, 4.0, 4.0)), hidden(symbol_square(1.0, 1.0, 3.0, 3.0)));
let dots = format!("%AI3_BeginPattern: (Dots)\n(Dots) 0 0 4 4\n{tile}E\n%AI3_EndPattern\n");
let palette = "%AI5_BeginPalette\n0 0 Pb\n(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n(Dots)\nPc\nPB\n%AI5_EndPalette\n";
let order = "%AI8_BeginPluginObject\n(Adobe Brush Manager Order)\n(Adobe Brush Manager Order)\n( Adobe Scatter Brush Tool/ Spray/) .\n%AI8_EndPluginObject\n";
let spray = "%AI8_BeginPluginObject\n(Adobe Scatter Brush Tool)\n(Spray)\n(2 /Dot art/ 5 1 0 0 / 0 0.5 1.5 1 ) -\n\
(1.2 2 0 -0.25 0.25 2 -45 30 1 1 0) .\n%AI8_EndPluginObject\n";
let dot = "%AI8_BeginBrushPattern\n(Dot art)\n(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n0 0 m\n4 0 L\n4 4 L\nf\n%AI8_EndBrushPattern\n";
for setup in [format!("{dots}{palette}"), format!("{dots}{dot}{order}{spray}")] {
let library = format!("%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%BeginSetup\n{setup}%%EndSetup\n%%PageTrailer\n%%Trailer\n");
let (d, warnings, read) = crate::layered_ai(&private(&library), pdf_part(10.0, 86.0), vec![], false, true, 1.0);
assert!(!read, "{warnings:?}");
let art = d.pattern("Dots").map(|p| p.art.clone()).unwrap_or_default();
let mut masked = art.iter().flat_map(|n| std::iter::once(n).chain(n.children().into_iter().flatten()));
assert!(masked.any(|n| n.extras().mask.is_some()), "the mask is read: {art:#?} {warnings:?}");
let mut ids = vec![];
for n in d.layers.iter().chain(d.patterns.iter().flat_map(|p| &p.art)) {
all_ids(n, &mut ids);
}
let unique: std::collections::HashSet<_> = ids.iter().copied().collect();
assert_eq!(unique.len(), ids.len(), "{ids:?}");
assert!(ids.iter().all(|id| id.0 < d.peek_next_id()), "{ids:?}");
}
}
/// A brush library (brush objects and their art, no layers) comes in as its PDF part with the
/// library's brushes in place of the default ones: Other Library lists them.
#[test]
@@ -1567,6 +1598,230 @@ fn a_file_whose_layers_cant_be_used_keeps_its_graphic_styles() {
assert_eq!(names(&r.document), ["Thin", "Medium", "Thick"], "{:?}", r.warnings);
}
/// A symbol named `name` whose art is `art` (in the symbol's space, y down), with the registration
/// point `registration` (1 to 9 from the top left, row by row), as the editing data defines it.
fn symbol_def(name: &str, art: &str, registration: u32) -> String {
format!(
"%AI14_BeginSymbol\n({name})\n0 A\n0 Xw\n0 Ae\nu\n{art}U\n%_/ArtDictionary :\n%_{registration} /Int (AI13PatternRegistrationType) ,\n%_;\n%_\n9 () XW\n%AI10_EndSymbol\n"
)
}
/// A red square from (`x0`, `y0`) to (`x1`, `y1`), as a symbol's art.
fn symbol_square(x0: f64, y0: f64, x1: f64, y1: f64) -> String {
format!("0 1 1 0 1 0 0 Xa\n{x0} {y0} m\n{x0} {y1} L\n{x1} {y1} L\n{x1} {y0} L\n{x0} {y0} L\nf\n")
}
/// The symbols Dot (a square of 4 about the origin, registered at its top left), Bar (12 × 2 about
/// the origin) and Masked (a square with an opacity mask), listed as Bar, Masked, Dot.
fn symbol_defs() -> String {
let mask_art: String = symbol_square(-2.0, -2.0, 2.0, 2.0).replace("0 1 1 0 1 0 0 Xa", "1 g").lines().map(|l| format!("%_{l}\n")).collect();
let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{mask_art}%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_; (AI9_Mask) ,\n%_;\n");
let masked = format!("{}{mask}{}", symbol_square(-2.0, -2.0, 2.0, 2.0), symbol_square(-1.0, -1.0, 1.0, 1.0));
let defs = symbol_def("Dot", &symbol_square(-2.0, -2.0, 2.0, 2.0), 1)
+ &symbol_def("Bar", &symbol_square(-6.0, -1.0, 6.0, 1.0), 5)
+ &symbol_def("Masked", &masked, 5);
format!("%AI5_Begin_NonPrinting\n{defs}%AI5_End_NonPrinting--\n%AI24_BeginSymbolList\n(Bar)\n(Masked)\n(Dot)\n%AI24_EndSymbolList\n")
}
/// A symbol library (the symbols of [`symbol_defs`], and no layers).
fn symbol_library() -> String {
format!("%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%BeginSetup\n{}%%EndSetup\n%%PageTrailer\n%%Trailer\n", symbol_defs())
}
/// The ids of `n`, what it holds and the art of its opacity masks.
fn all_ids(n: &Node, out: &mut Vec<vectorcraft_doc::NodeId>) {
out.push(n.id);
if let Some(m) = n.extras().mask.as_ref() {
all_ids(&m.art, out);
}
for c in n.children().into_iter().flatten() {
all_ids(c, out);
}
}
/// The names of the symbols of `d`, in the Symbols panel's order.
fn symbol_names(d: &Document) -> Vec<&str> {
d.symbols.iter().map(|s| s.name.as_str()).collect()
}
/// A symbol library comes in as its PDF part with the library's symbols, in the list's order, with
/// their registration points: Other Library lists them. Their art takes ids of the document, the
/// art of an opacity mask in it included.
#[test]
fn an_ai_symbol_library_without_layers_keeps_its_symbols() {
let (d, warnings, read) = crate::layered_ai(&private(&symbol_library()), pdf_part(10.0, 86.0), vec![], false, true, 1.0);
assert!(!read, "{warnings:?}");
assert_eq!(symbol_names(&d), ["Bar", "Masked", "Dot"], "{warnings:?}");
assert_eq!(d.symbol_registration("Dot"), vectorcraft_doc::SymbolRegistration(0));
assert!(d.symbol_registration("Bar").is_center());
let boxes = d.symbol_boxes();
let bar = d.symbols.first().and_then(|s| s.art.visual_bounds_with(&boxes));
assert_eq!(bar, Some(vectorcraft_geom::Rect::new(-6.0, -1.0, 6.0, 1.0)));
drop(boxes);
let mut ids = vec![];
for n in d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)) {
all_ids(n, &mut ids);
}
let masked = d.symbols.get(1).map(|s| &s.art);
assert!(masked.is_some_and(|a| a.children().into_iter().flatten().any(|c| c.extras().mask.is_some())), "the mask is read");
let unique: std::collections::HashSet<_> = ids.iter().copied().collect();
assert_eq!(unique.len(), ids.len(), "{ids:?}");
assert!(warnings.is_empty(), "{warnings:?}");
// Without symbols, the PDF part's (none) stay.
let (d, warnings, _) = crate::layered_ai(&private(&native("")), pdf_part(10.0, 86.0), vec![], false, true, 1.0);
assert!(d.symbols.is_empty(), "{warnings:?}");
}
/// An EPS whose editing copy has symbols and no layers comes in as its page with those symbols.
#[test]
fn an_eps_without_layers_keeps_its_symbols() {
let r = import(&eps(&page_square(50, 50), &symbol_library())).unwrap();
assert_eq!(symbol_names(&r.document), ["Bar", "Masked", "Dot"], "{:?}", r.warnings);
}
/// A `.ai` that comes in as its PDF part, and an EPS that comes in as its page, because their
/// layers can't be used (their editing data is for another page) get the symbols defined before
/// their layers.
#[test]
fn a_file_whose_layers_cant_be_used_keeps_its_symbols() {
let art = native(&format!("{}{}", symbol_defs(), layer("Art", true, &square(10, 10)))).replace(" 0 0 100 100", " 0 0 600 400");
let (d, warnings, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0);
assert!(!read && warnings.iter().any(|w| w.contains("600 × 400")), "{warnings:?}");
assert_eq!(symbol_names(&d), ["Bar", "Masked", "Dot"], "{warnings:?}");
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("600 × 400")), "{:?}", r.warnings);
assert_eq!(symbol_names(&r.document), ["Bar", "Masked", "Dot"], "{:?}", r.warnings);
}
/// A symbol library's symbols bring the spot colors their art is linked to. A color whose name a
/// swatch of the library's palette has for another color comes in under a free variant of the
/// name, which the art then names, as it does for an ink of the PDF part; without a palette, it
/// takes the place of the default swatch of that name.
#[test]
fn an_ai_symbol_librarys_symbols_keep_their_spot_colors() {
let library = |setup: &str, ink: &str| {
let art = format!("0 0 1 0 ({ink}) 1 x\n-2 -2 m\n-2 2 L\n2 2 L\n2 -2 L\n-2 -2 L\nf\n");
let defs = format!("{setup}%AI5_Begin_NonPrinting\n{}%AI5_End_NonPrinting--\n", symbol_def("Inked", &art, 5));
format!("%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%BeginSetup\n{defs}%%EndSetup\n%%PageTrailer\n%%Trailer\n")
};
let open = |text: &str| crate::layered_ai(&private(text), pdf_part(10.0, 86.0), vec![], false, true, 1.0);
let link = |d: &Document| match d.symbols.first().and_then(|s| s.art.appearance.fill()).map(|f| &f.paint) {
Some(vectorcraft_color::Paint::Solid { swatch, .. }) => swatch.clone(),
_ => None,
};
let ink = |d: &Document, name: &str| d.swatch(name).map(|s| (s.paint.color(), s.spot));
let yellow = Some(vectorcraft_color::Color::cmyk(0.0, 0.0, 1.0, 0.0));
let (d, warnings, _) = open(&library("", "Brand Ink"));
assert_eq!(link(&d).as_deref(), Some("Brand Ink"), "{warnings:?}");
assert_eq!(ink(&d, "Brand Ink"), Some((yellow, true)));
let palette = "%AI5_BeginPalette\n0 0 Pb\n0 0 0 0 0 1 0 Xa\n(Clash)\nPc\nPB\n%AI5_EndPalette\n";
let (d, warnings, _) = open(&library(palette, "Clash"));
assert_eq!(link(&d).as_deref(), Some("Clash 2"), "{warnings:?}");
assert_eq!(ink(&d, "Clash 2"), Some((yellow, true)));
assert_eq!(ink(&d, "Clash"), Some((Some(vectorcraft_color::Color::rgb(0.0, 1.0, 0.0)), false)));
let (d, warnings, _) = open(&library("", "Red"));
assert_eq!(link(&d).as_deref(), Some("Red"), "{warnings:?}");
assert_eq!(ink(&d, "Red"), Some((yellow, true)));
assert_eq!(d.swatches.iter().filter(|s| s.name == "Red").count(), 1);
// An ink of the PDF part keeps its swatch: the library's color of that name is renamed.
let mut part = pdf_part(10.0, 86.0);
let green = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(1.0, 0.0, 1.0, 0.0));
part.swatches.push(vectorcraft_color::Swatch { name: "Ink".into(), paint: green.clone(), global: true, spot: true });
let (d, warnings, _) = crate::layered_ai(&private(&library("", "Ink")), part, vec![], false, true, 1.0);
assert_eq!(link(&d).as_deref(), Some("Ink 2"), "{warnings:?}");
assert_eq!(ink(&d, "Ink 2"), Some((yellow, true)));
assert_eq!(d.swatch("Ink").map(|s| &s.paint), Some(&green));
// A color group of the PDF part keeps its name: the library's color of that name is renamed.
let mut part = pdf_part(10.0, 86.0);
part.swatch_groups.push(vectorcraft_color::SwatchGroup { name: "Kit".into(), swatches: vec![] });
let (d, warnings, _) = crate::layered_ai(&private(&library("", "Kit")), part, vec![], false, true, 1.0);
assert_eq!(link(&d).as_deref(), Some("Kit 2"), "{warnings:?}");
assert_eq!(ink(&d, "Kit 2"), Some((yellow, true)));
assert!(d.swatch("Kit").is_none());
}
/// A symbol library's symbols bring a global process color their art is filled with as a global
/// swatch that isn't an ink, and a spot color a stop of their gradient names as an ink.
#[test]
fn an_ai_symbol_librarys_symbols_keep_their_global_and_gradient_stop_colors() {
let gradient = "%AI5_BeginGradient: (Fade)\n(Fade) 0 2 Bd\n[\n\
%_0 1 1 0 (Brand) 0 0 5 1 6 50 0 Bs\n%_0 0 0 0 1 0 0 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
let global = "0 0 1 0 (Process Yellow) 0 0 Xk\n-2 -2 m\n-2 2 L\n2 2 L\n2 -2 L\n-2 -2 L\nf\n";
let graded = "Bb\n1 (Fade) 0 0 0 4 1 0 0 1 0 0 Bg\n-2 -2 m\n-2 2 L\n2 2 L\n2 -2 L\n-2 -2 L\nf\n0 BB\n";
let defs = symbol_def("Global", global, 5) + &symbol_def("Graded", graded, 5);
let text = format!(
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%BeginSetup\n{gradient}%AI5_Begin_NonPrinting\n{defs}%AI5_End_NonPrinting--\n\
%%EndSetup\n%%PageTrailer\n%%Trailer\n"
);
let (d, warnings, _) = crate::layered_ai(&private(&text), pdf_part(10.0, 86.0), vec![], false, true, 1.0);
assert_eq!(symbol_names(&d), ["Global", "Graded"], "{warnings:?}");
let swatch = |name: &str| d.swatch(name).map(|s| (s.paint.color(), s.global, s.spot));
assert_eq!(swatch("Process Yellow"), Some((Some(vectorcraft_color::Color::cmyk(0.0, 0.0, 1.0, 0.0)), true, false)), "{warnings:?}");
assert_eq!(swatch("Brand"), Some((Some(vectorcraft_color::Color::cmyk(0.0, 1.0, 1.0, 0.0)), true, true)), "{warnings:?}");
}
/// A symbol library's symbol shows its image from the document's images, and an object of a
/// symbol's art linked to a style of the library's style list links to that style as the document
/// has it.
#[test]
fn an_ai_symbol_librarys_symbols_bring_their_images_and_style_links() {
// The template box's center is (100.5, 50.5): art space's origin is at canvas (8091, 8242).
let m = "[ 5 0 0 5 -8096 8244.5 ]";
let raster = format!(
"%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceRGB XN\n{m} 2 1 0 Xh\n{m} 0 0 2 1 2 1 8 3 0 0 1 0 4 4 0 0\n\
%%BeginData: 6\rXI\nABCDEF%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\n"
);
let path = "-2 -2 m\n-2 2 L\n2 2 L\n2 -2 L\n-2 -2 L\n";
let hidden: String = path.lines().map(|l| format!("%_{l}\n")).collect();
let styled = format!("0 Ae\nu\n0 0 0 0 0 0 1 XA\n0 J 0 j 3 w 10 M []0 d\n{path}S\nU\n{hidden}%_S\n1 (Thick) XW\n");
let defs = format!("%AI5_Begin_NonPrinting\n{}{}%AI5_End_NonPrinting--\n", symbol_def("Pic", &raster, 5), symbol_def("Line", &styled, 5));
let text = style_library(&defs).replacen("%%BeginSetup", "%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n%%BeginSetup", 1);
let (d, warnings, read) = crate::layered_ai(&private(&text), pdf_part(10.0, 86.0), vec![], false, true, 1.0);
assert!(!read, "{warnings:?}");
assert_eq!(symbol_names(&d), ["Pic", "Line"], "{warnings:?}");
let key = d.symbols.first().and_then(|s| if let NodeKind::Image(im) = &s.art.kind { Some(im.key.clone()) } else { None });
let key = key.unwrap_or_else(|| panic!("an image: {:#?}", d.symbols.first()));
assert!(d.images.contains_key(&key), "{key}");
let mut linked = vec![];
let mut stack: Vec<&Node> = d.symbols.get(1).map(|s| &*s.art).into_iter().collect();
while let Some(n) = stack.pop() {
linked.extend(n.extras().graphic_style);
stack.extend(n.children().into_iter().flatten().map(|c| &**c));
}
let named: Vec<&str> = linked.iter().filter_map(|id| d.graphic_styles.iter().find(|g| g.id == *id)).map(|g| g.name.as_str()).collect();
assert_eq!(named, ["Thick"], "{linked:?}");
}
/// The instances of a symbol library's symbols that would draw more than
/// [`vectorcraft_doc::depth::MAX_DRAWN_DEPTH`] objects deep go, with the note saying so, and a
/// document that has symbols already keeps them.
#[test]
fn an_ai_symbol_librarys_instances_drawn_too_deep_go() {
// C0 is a square, and each later symbol holds an instance of the one before it. The template
// box's center is (100.5, 50.5): art space's origin is at canvas (8091, 8242).
let instance = |name: &str| format!("/SymbolInstance :\n({name}) /SymbolRef ,\n1 0 0 -1 -8091 8242 /RTransformMatrix\n;\n9 () XW\n");
let count = vectorcraft_doc::depth::MAX_DRAWN_DEPTH + 10;
let mut defs = symbol_def("C0", &symbol_square(-2.0, -2.0, 2.0, 2.0), 5);
for i in 1..count {
defs.push_str(&symbol_def(&format!("C{i}"), &instance(&format!("C{}", i - 1)), 5));
}
let text = format!(
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n%%BeginSetup\n\
%AI5_Begin_NonPrinting\n{defs}%AI5_End_NonPrinting--\n%%EndSetup\n%%PageTrailer\n%%Trailer\n"
);
let (d, warnings, _) = crate::layered_ai(&private(&text), pdf_part(10.0, 86.0), vec![], false, true, 1.0);
assert_eq!(d.symbols.len(), count, "{warnings:?}");
let instances = d.symbols.iter().filter(|s| matches!(s.art.kind, NodeKind::SymbolInstance { .. })).count();
assert!(instances < count - 1, "{instances}");
assert!(warnings.contains(&vectorcraft_doc::depth::symbols_note()), "{warnings:?}");
// A document with symbols keeps its own.
let mut part = pdf_part(10.0, 86.0);
let mine = Node::group(part.alloc_id(), vec![]);
part.symbols.push(vectorcraft_doc::Symbol { name: "Mine".into(), art: std::sync::Arc::new(mine), dynamic: false });
let (d, warnings, _) = crate::layered_ai(&private(&text), part, vec![], false, true, 1.0);
assert_eq!(symbol_names(&d), ["Mine"], "{warnings:?}");
}
/// A PDF part whose tiling has the name of a pattern its editing data defines (the PDF part names
/// its tilings `Pattern 1`, `Pattern 2` and so on): the editing data's pattern comes in under a free
/// variant of the name, and the palette's swatch and the brush's art paint with it, not with the
+18 -8
View File
@@ -409,17 +409,18 @@ pub fn arc(a: Point, b: Point, slope: f64, closed: bool, _base_along_x: bool) ->
PathData::single(SubPath::new(anchors, closed))
}
/// Logarithmic spiral approximating the reference app's Spiral tool (decay-based, `segments` quarter turns).
/// Logarithmic spiral with decay per full wind (`segments` quarter turns).
pub fn spiral(center: Point, radius: f64, decay_percent: f64, segments: u32, clockwise: bool) -> PathData {
let decay = (decay_percent / 100.0).clamp(0.05, 0.9999);
let step_decay = decay.powf(0.25);
let dir = if clockwise { 1.0 } else { -1.0 };
// The radius shrinks by `decay` each quarter turn: r(φ) = radius · e^(kφ) after turning φ.
let k = decay.ln() / (PI / 2.0);
// The radius shrinks by `decay` each full wind: r(φ) = radius · e^(kφ).
let k = decay.ln() / TAU;
// Handle lengths per unit of the derivative below, at a segment's outer and inner end: √r
// changes evenly with the curve's parameter along each quarter turn. Both are KAPPA when the
// radius does not shrink, and the curve stays within 0.8% of the spiral's radius at 20% decay.
let outer = 2.0 * KAPPA * (decay.sqrt() - 1.0) / decay.ln();
let inner = outer / decay.sqrt();
let outer = 2.0 * KAPPA * (step_decay.sqrt() - 1.0) / step_decay.ln();
let inner = outer / step_decay.sqrt();
let mut anchors = Vec::new();
let mut r = radius;
for i in 0..=segments {
@@ -431,7 +432,7 @@ pub fn spiral(center: Point, radius: f64, decay_percent: f64, segments: u32, clo
let h_in = p - d * inner;
let h_out = p + d * outer;
anchors.push(Anchor { p, h_in: if i == 0 { p } else { h_in }, h_out: if i == segments { p } else { h_out }, kind: AnchorKind::Smooth });
r *= decay;
r *= step_decay;
}
// Illustrator spirals wind inward from the outer point; reverse so the path starts at the centre end.
let mut sp = SubPath::new(anchors, false);
@@ -796,8 +797,17 @@ mod tests {
assert!(!p.subpaths[0].closed);
}
#[test]
fn spiral_decay_is_per_full_wind() {
let p = spiral(Point::ZERO, 100.0, 80.0, 8, true);
let radii: Vec<_> = p.subpaths[0].anchors.iter().map(|a| (a.p - Point::ZERO).hypot()).collect();
assert!((radii[8] - 100.0).abs() < 1e-9);
assert!((radii[4] - 80.0).abs() < 1e-9);
assert!((radii[0] - 64.0).abs() < 1e-9);
}
/// #3437: every segment stays on the spiral through its anchors, whose radius shrinks by
/// `decay` each quarter turn, for strong and weak decay and both directions.
/// `decay` each full wind, for strong and weak decay and both directions.
#[test]
fn spiral_segments_follow_the_spiral() {
let center = Point::new(40.0, -25.0);
@@ -805,7 +815,7 @@ mod tests {
for clockwise in [true, false] {
let p = spiral(center, 100.0, decay, 8, clockwise);
let anchors = &p.subpaths[0].anchors;
let k = (decay / 100.0_f64).ln() / (PI / 2.0);
let k = (decay / 100.0_f64).ln() / TAU;
let mut worst = 0.0_f64;
for pair in anchors.windows(2) {
let c = kurbo::CubicBez::new(pair[0].p, pair[0].h_out, pair[1].h_in, pair[1].p);
+1
View File
@@ -10,6 +10,7 @@ rust-version.workspace = true
workspace = true
[dependencies]
chrono = { workspace = true }
unicode-general-category = "1"
[dev-dependencies]
+15 -9
View File
@@ -1,7 +1,8 @@
//! The built-in objects the interpreter itself needs (ECMA-262 5.1 section 15): `Object` and the
//! basic methods of `Object.prototype`, `Function` with `Function.prototype.call`, `apply` and
//! `toString`, `eval`, the error constructors and `Error.prototype.toString`, and the prototypes
//! primitive values look properties up on. The rest of the standard library is not here yet.
//! primitive values look properties up on. `String`, `Number`, `RegExp` and `Date` are in their
//! own modules; the rest of the standard library is not here yet.
use std::rc::Rc;
@@ -16,31 +17,32 @@ fn alloc(interp: &mut Interp, class: Class, proto: Option<ObjId>) -> ObjId {
}
/// A built-in function object.
fn native(interp: &mut Interp, name: &'static str, f: NativeFn, length: u32, kind: NativeKind, constructor: bool) -> JsResult<ObjId> {
pub(crate) fn native(interp: &mut Interp, name: &'static str, f: NativeFn, length: u32, kind: NativeKind, constructor: bool) -> JsResult<ObjId> {
let proto = interp.intrinsics.function_proto;
let o = alloc(interp, Class::Function(Func::Native { f, kind, constructor, name }), Some(proto));
interp.define(o, key("length"), Prop::Data { value: Value::Number(f64::from(length)), flags: 0 })?;
Ok(o)
}
fn method(interp: &mut Interp, on: ObjId, name: &'static str, f: NativeFn, length: u32) -> JsResult<()> {
pub(crate) fn method(interp: &mut Interp, on: ObjId, name: &'static str, f: NativeFn, length: u32) -> JsResult<()> {
let m = native(interp, name, f, length, NativeKind::Plain, false)?;
interp.define(on, key(name), Prop::Data { value: Value::Object(m), flags: HIDDEN })
}
fn data(interp: &mut Interp, on: ObjId, name: &str, value: Value, flags: u8) -> JsResult<()> {
pub(crate) fn data(interp: &mut Interp, on: ObjId, name: &str, value: Value, flags: u8) -> JsResult<()> {
interp.define(on, key(name), Prop::Data { value, flags })
}
/// Links a constructor and its prototype and makes the constructor a global.
fn constructor(interp: &mut Interp, name: &'static str, ctor: ObjId, proto: ObjId) -> JsResult<()> {
pub(crate) fn constructor(interp: &mut Interp, name: &'static str, ctor: ObjId, proto: ObjId) -> JsResult<()> {
data(interp, ctor, "prototype", Value::Object(proto), 0)?;
data(interp, proto, "constructor", Value::Object(ctor), HIDDEN)?;
let global = interp.global;
data(interp, global, name, Value::Object(ctor), HIDDEN)
}
/// Creates the built-ins and the global object's properties.
/// Creates the built-ins and the global object's properties. The caller installs them before it
/// sets the realm's limits, so no limit stops the install.
pub(crate) fn install(interp: &mut Interp) {
if let Err(e) = try_install(interp) {
// Installing touches only objects it has just made, so this does not happen; a realm
@@ -65,7 +67,7 @@ fn try_install(interp: &mut Interp) -> JsResult<()> {
}
interp.intrinsics.array_proto = alloc(interp, Class::Array { length: 0, length_writable: true }, Some(object_proto));
interp.intrinsics.string_proto = alloc(interp, Class::String(Str::from("")), Some(object_proto));
interp.intrinsics.number_proto = alloc(interp, Class::Number, Some(object_proto));
interp.intrinsics.number_proto = alloc(interp, Class::Number(0.0), Some(object_proto));
interp.intrinsics.boolean_proto = alloc(interp, Class::Boolean, Some(object_proto));
interp.intrinsics.regexp_proto = alloc(interp, Class::Ordinary, Some(object_proto));
let thrower = native(interp, "", throw_type_error, 0, NativeKind::Plain, false)?;
@@ -98,7 +100,11 @@ fn try_install(interp: &mut Interp) -> JsResult<()> {
data(interp, function_proto, "apply", Value::Object(apply), HIDDEN)?;
method(interp, function_proto, "toString", function_to_string, 0)?;
install_errors(interp, object_proto)
install_errors(interp, object_proto)?;
crate::string_builtins::install(interp)?;
crate::number_builtins::install(interp)?;
crate::regexp_builtins::install(interp)?;
crate::date_builtins::install(interp)
}
fn install_errors(interp: &mut Interp, object_proto: ObjId) -> JsResult<()> {
@@ -131,7 +137,7 @@ fn install_errors(interp: &mut Interp, object_proto: ObjId) -> JsResult<()> {
Ok(())
}
fn arg(args: &[Value], i: usize) -> Value {
pub(crate) fn arg(args: &[Value], i: usize) -> Value {
args.get(i).cloned().unwrap_or(Value::Undefined)
}
+845
View File
@@ -0,0 +1,845 @@
//! `Date` (ECMA-262 5.1 section 15.9), with `getYear`, `setYear` and `toGMTString` from Annex B.
//!
//! Time values are milliseconds since 1970-01-01 UTC (section 15.9.1). `chrono` turns days into
//! calendar dates and back; past the years it represents, dates are shifted by whole 400-year
//! cycles. The current time comes from the host's clock and local time from the host's
//! time zone ([`DateHost`]): by default the system clock (none in a wasm build, where `Date.now()`
//! is NaN until the host sets one) and UTC. `Date.parse` reads the format of section 15.9.1.15
//! and the formats `toString` and `toUTCString` write.
use std::rc::Rc;
use chrono::{Datelike, NaiveDate};
use crate::builtins::{arg, constructor, data, method, native};
use crate::heap::{Class, NativeFn, NativeKind, Object, key};
use crate::interp::{ErrorType, HeldBytes, Hint, Interp, JsResult};
use crate::string_builtins::{charge_units, flat_charged, reserve_units, to_integer};
use crate::value::{Str, Value};
/// The clock and time zone a realm's `Date` reads.
#[derive(Clone)]
pub(crate) struct DateHost {
/// The current time value.
pub(crate) clock: Rc<dyn Fn() -> f64>,
/// The offset of local time from UTC, in milliseconds, at a UTC time value.
pub(crate) offset: Rc<dyn Fn(f64) -> f64>,
}
impl Default for DateHost {
fn default() -> Self {
Self { clock: Rc::new(system_clock), offset: Rc::new(|_| 0.0) }
}
}
#[cfg(not(target_arch = "wasm32"))]
fn system_clock() -> f64 {
match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
Ok(d) => d.as_millis() as f64,
Err(e) => -(e.duration().as_millis() as f64),
}
}
#[cfg(target_arch = "wasm32")]
fn system_clock() -> f64 {
f64::NAN
}
const MS_PER_DAY: f64 = 86_400_000.0;
const MS_PER_HOUR: f64 = 3_600_000.0;
const MS_PER_MINUTE: f64 = 60_000.0;
const MS_PER_SECOND: f64 = 1000.0;
const DAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
// ---- section 15.9.1 ----
fn day(t: f64) -> f64 {
(t / MS_PER_DAY).floor()
}
fn time_within_day(t: f64) -> f64 {
t.rem_euclid(MS_PER_DAY)
}
/// `chrono`'s day number (0001-01-01 is day 1) of 1970-01-01.
const EPOCH_CE_DAYS: i64 = 719_163;
/// The days in 400 Gregorian years, after which the calendar repeats.
const CYCLE_DAYS: i64 = 146_097;
/// The days from 1970-01-01 to 2000-01-01, where the 400-year cycle `chrono` is asked about starts.
const DAYS_TO_2000: i64 = 10_957;
/// The year, the month (0 to 11) and the day of the month of a day (days since 1970-01-01).
/// `chrono` computes them for the matching day of the 400-year cycle starting in 2000, and the
/// whole cycles are added back to the year, so days past `chrono`'s range have dates too.
fn civil(day: f64) -> Option<(f64, f64, f64)> {
if !day.is_finite() || day.abs() > 1e9 {
return None;
}
let d = day as i64;
let cycles = (d - DAYS_TO_2000).div_euclid(CYCLE_DAYS);
let date = NaiveDate::from_num_days_from_ce_opt(i32::try_from(d - cycles * CYCLE_DAYS + EPOCH_CE_DAYS).ok()?)?;
Some((f64::from(date.year()) + 400.0 * cycles as f64, f64::from(date.month0()), f64::from(date.day())))
}
/// The year of the 400-year cycle starting in 2000 that has `year`'s calendar, and the number of
/// whole cycles between the two.
fn cycle_year(year: f64) -> Option<(i32, i64)> {
if !year.is_finite() || year.abs() > 1e7 {
return None;
}
let y = year as i64;
let cycles = (y - 2000).div_euclid(400);
Some((i32::try_from(y - 400 * cycles).ok()?, cycles))
}
/// The day (days since 1970-01-01) on which month `month0` (0 to 11) of `year` starts.
fn first_day_of_month(year: f64, month0: u32) -> Option<f64> {
let (y, cycles) = cycle_year(year)?;
let date = NaiveDate::from_ymd_opt(y, month0 + 1, 1)?;
Some((i64::from(date.num_days_from_ce()) - EPOCH_CE_DAYS + cycles * CYCLE_DAYS) as f64)
}
/// Month `month` (1 to 12) of `year` has a day `date`.
fn is_date(year: f64, month: f64, date: f64) -> bool {
(1.0..=12.0).contains(&month)
&& (1.0..=31.0).contains(&date)
&& cycle_year(year).is_some_and(|(y, _)| NaiveDate::from_ymd_opt(y, month as u32, date as u32).is_some())
}
fn year_from_time(t: f64) -> f64 {
civil(day(t)).map_or(f64::NAN, |(y, _, _)| y)
}
fn month_from_time(t: f64) -> f64 {
civil(day(t)).map_or(f64::NAN, |(_, m, _)| m)
}
fn date_from_time(t: f64) -> f64 {
civil(day(t)).map_or(f64::NAN, |(_, _, d)| d)
}
fn week_day(t: f64) -> f64 {
(day(t) + 4.0).rem_euclid(7.0)
}
fn hour_from_time(t: f64) -> f64 {
(t / MS_PER_HOUR).floor().rem_euclid(24.0)
}
fn min_from_time(t: f64) -> f64 {
(t / MS_PER_MINUTE).floor().rem_euclid(60.0)
}
fn sec_from_time(t: f64) -> f64 {
(t / MS_PER_SECOND).floor().rem_euclid(60.0)
}
fn ms_from_time(t: f64) -> f64 {
t.rem_euclid(MS_PER_SECOND)
}
fn make_time(h: f64, m: f64, s: f64, ms: f64) -> f64 {
if !(h.is_finite() && m.is_finite() && s.is_finite() && ms.is_finite()) {
return f64::NAN;
}
to_integer(h) * MS_PER_HOUR + to_integer(m) * MS_PER_MINUTE + to_integer(s) * MS_PER_SECOND + to_integer(ms)
}
fn make_day(year: f64, month: f64, date: f64) -> f64 {
if !(year.is_finite() && month.is_finite() && date.is_finite()) {
return f64::NAN;
}
let (y, m, dt) = (to_integer(year), to_integer(month), to_integer(date));
let ym = y + (m / 12.0).floor();
// Years this far out are past every time value TimeClip keeps.
if ym.abs() > 400_000.0 {
return f64::NAN;
}
first_day_of_month(ym, m.rem_euclid(12.0) as u32).map_or(f64::NAN, |first| first + dt - 1.0)
}
fn make_date(day: f64, time: f64) -> f64 {
if !(day.is_finite() && time.is_finite()) {
return f64::NAN;
}
day * MS_PER_DAY + time
}
fn time_clip(t: f64) -> f64 {
if !t.is_finite() || t.abs() > 8.64e15 { f64::NAN } else { to_integer(t) }
}
impl Interp {
/// The host's offset of local time from UTC at `utc`; an offset that is not finite or is a
/// day or more counts as 0.
fn local_offset(&self, utc: f64) -> f64 {
let o = (self.date_host.offset)(utc);
if o.is_finite() && o.abs() < MS_PER_DAY { o } else { 0.0 }
}
/// LocalTime (section 15.9.1.9).
fn local_time(&self, t: f64) -> f64 {
t + self.local_offset(t)
}
/// UTC (section 15.9.1.9): the time value of a local time.
fn utc(&self, t: f64) -> f64 {
if !t.is_finite() {
return f64::NAN;
}
let guess = t - self.local_offset(t);
t - self.local_offset(guess)
}
fn now(&self) -> f64 {
time_clip((self.date_host.clock)())
}
}
// ---- formatting ----
fn year_text(y: f64) -> String {
if y < 0.0 { format!("-{:06}", -y as i64) } else { format!("{:04}", y as i64) }
}
fn offset_text(offset_ms: f64) -> String {
let minutes = (offset_ms / MS_PER_MINUTE).round() as i64;
let sign = if minutes < 0 { '-' } else { '+' };
format!("GMT{sign}{:02}{:02}", minutes.abs() / 60, minutes.abs() % 60)
}
fn date_text(t: f64) -> String {
let wd = DAYS.get(week_day(t) as usize).copied().unwrap_or("");
let mon = MONTHS.get(month_from_time(t) as usize).copied().unwrap_or("");
format!("{wd} {mon} {:02} {}", date_from_time(t) as i64, year_text(year_from_time(t)))
}
fn time_text(t: f64) -> String {
format!("{:02}:{:02}:{:02}", hour_from_time(t) as i64, min_from_time(t) as i64, sec_from_time(t) as i64)
}
impl Interp {
/// `toString`'s format: `Tue Nov 07 2026 12:00:00 GMT+0100`.
fn date_to_string(&self, t: f64) -> String {
if t.is_nan() {
return "Invalid Date".into();
}
let local = self.local_time(t);
format!("{} {} {}", date_text(local), time_text(local), offset_text(local - t))
}
}
fn utc_text(t: f64) -> String {
if t.is_nan() {
return "Invalid Date".into();
}
let wd = DAYS.get(week_day(t) as usize).copied().unwrap_or("");
let mon = MONTHS.get(month_from_time(t) as usize).copied().unwrap_or("");
format!("{wd}, {:02} {mon} {} {} GMT", date_from_time(t) as i64, year_text(year_from_time(t)), time_text(t))
}
fn iso_text(t: f64) -> String {
let y = year_from_time(t);
let year = if (0.0..=9999.0).contains(&y) {
format!("{:04}", y as i64)
} else if y < 0.0 {
format!("-{:06}", -y as i64)
} else {
format!("+{:06}", y as i64)
};
format!("{year}-{:02}-{:02}T{}.{:03}Z", month_from_time(t) as i64 + 1, date_from_time(t) as i64, time_text(t), ms_from_time(t) as i64)
}
// ---- parsing ----
/// Reads the format of section 15.9.1.15: `YYYY[-MM[-DD]][THH:mm[:ss[.sss]][Z|±HH:mm]]`, with
/// six-digit signed years. A missing offset means UTC.
fn parse_iso(s: &[u8]) -> Option<f64> {
let mut i = 0;
let num = |i: &mut usize, n: usize| -> Option<f64> {
let part = s.get(*i..*i + n)?;
if !part.iter().all(u8::is_ascii_digit) {
return None;
}
*i += n;
Some(part.iter().fold(0.0, |v, d| v * 10.0 + f64::from(d - b'0')))
};
let year = match s.first() {
Some(b'+') | Some(b'-') => {
let neg = s.first() == Some(&b'-');
i = 1;
let y = num(&mut i, 6)?;
if neg && y == 0.0 {
return None;
}
if neg { -y } else { y }
}
_ => num(&mut i, 4)?,
};
let (mut month, mut date) = (1.0, 1.0);
if s.get(i) == Some(&b'-') {
i += 1;
month = num(&mut i, 2)?;
if s.get(i) == Some(&b'-') {
i += 1;
date = num(&mut i, 2)?;
}
}
let (mut h, mut m, mut sec, mut ms) = (0.0, 0.0, 0.0, 0.0);
let mut offset = 0.0;
if s.get(i) == Some(&b'T') {
i += 1;
h = num(&mut i, 2)?;
if s.get(i) != Some(&b':') {
return None;
}
i += 1;
m = num(&mut i, 2)?;
if s.get(i) == Some(&b':') {
i += 1;
sec = num(&mut i, 2)?;
if s.get(i) == Some(&b'.') {
i += 1;
let start = i;
while s.get(i).is_some_and(u8::is_ascii_digit) {
i += 1;
}
let digits = s.get(start..i)?;
if digits.is_empty() {
return None;
}
ms = digits.iter().take(3).fold(0.0, |v, d| v * 10.0 + f64::from(d - b'0')) * 10f64.powi(3 - digits.len().min(3) as i32);
}
}
match s.get(i) {
Some(b'Z') => i += 1,
Some(&c @ (b'+' | b'-')) => {
i += 1;
let oh = num(&mut i, 2)?;
if s.get(i) != Some(&b':') {
return None;
}
i += 1;
let om = num(&mut i, 2)?;
if oh > 23.0 || om > 59.0 {
return None;
}
offset = (oh * 60.0 + om) * MS_PER_MINUTE * if c == b'-' { -1.0 } else { 1.0 };
}
_ => {}
}
}
if i != s.len() {
return None;
}
let valid = is_date(year, month, date) && (h < 24.0 || (h == 24.0 && m == 0.0 && sec == 0.0 && ms == 0.0)) && m < 60.0 && sec < 60.0;
if !valid {
return None;
}
let t = make_date(make_day(year, month - 1.0, date), make_time(h, m, sec, ms)) - offset;
Some(time_clip(t))
}
/// Reads the formats `toString` and `toUTCString` write: a weekday name, a month name, a day, a
/// year, an optional `HH:mm[:ss]` time and an optional `GMT[±hhmm]` offset, in either order of
/// day and month. Without an offset the time is local.
fn parse_text(interp: &Interp, s: &str) -> Option<f64> {
let starts = |w: &str, prefix: &str| w.get(..prefix.len()).is_some_and(|head| head.eq_ignore_ascii_case(prefix));
let (mut month, mut date, mut year) = (None, None, None);
let (mut h, mut m, mut sec) = (0.0, 0.0, 0.0);
let mut offset: Option<f64> = None;
// The words are read one at a time, and none is copied.
let words = s.split(|c: char| c.is_whitespace() || c == ',').take_while(|w| !w.starts_with('(')).filter(|w| !w.is_empty());
for w in words {
if DAYS.iter().any(|d| starts(w, d)) && month.is_none() && date.is_none() && w.len() >= 3 && !w.contains(':') {
continue;
}
if let Some(mi) = MONTHS.iter().position(|mo| starts(w, mo)) {
month = Some(mi as f64);
continue;
}
let zone = if starts(w, "gmt") || starts(w, "utc") { w.get(3..) } else { w.eq_ignore_ascii_case("z").then_some("") };
if let Some(rest) = zone {
if rest.is_empty() {
offset = Some(0.0);
continue;
}
let (sign, digits) = match rest.as_bytes().first() {
Some(b'+') => (1.0, rest.get(1..)?),
Some(b'-') => (-1.0, rest.get(1..)?),
_ => return None,
};
// Five units are enough to tell whether four digits remain without the colons.
let digits: String = digits.chars().filter(|&c| c != ':').take(5).collect();
if digits.len() != 4 || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let v: f64 = digits.parse().ok()?;
offset = Some(sign * ((v / 100.0).floor() * 60.0 + v % 100.0) * MS_PER_MINUTE);
continue;
}
if w.contains(':') {
if w.split(':').count() > 3 {
return None;
}
let parts: Vec<&str> = w.split(':').collect();
let field = |i: usize| -> Option<f64> { parts.get(i).map(|p| p.parse::<f64>().ok()).unwrap_or(Some(0.0)) };
if parts.len() < 2 || parts.len() > 3 || !parts.iter().all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) {
return None;
}
(h, m, sec) = (field(0)?, field(1)?, field(2)?);
if h > 24.0 || m > 59.0 || sec > 59.0 {
return None;
}
continue;
}
let (neg, digits) = match w.strip_prefix('-') {
Some(d) => (true, d),
None => (false, w),
};
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let v: f64 = digits.parse().ok()?;
if date.is_none() && !neg && digits.len() <= 2 {
date = Some(v);
} else if year.is_none() {
year = Some(if neg { -v } else { v });
} else {
return None;
}
}
let (month, date, year) = (month?, date?, year?);
if !(1.0..=31.0).contains(&date) {
return None;
}
let local = make_date(make_day(year, month, date), make_time(h, m, sec, 0.0));
let t = match offset {
Some(o) => local - o,
None => interp.utc(local),
};
Some(time_clip(t))
}
/// Takes fuel for the units of `s` the parsers read. The text the parsers read is held against
/// the heap limit while they read it.
fn parse_date(interp: &mut Interp, s: &Str) -> JsResult<f64> {
let units = flat_charged(interp, s)?;
charge_units(interp, units.len())?;
let mut held = HeldBytes::new(interp);
// Each code unit becomes at most three UTF-8 bytes.
held.add(interp, units.len().saturating_mul(3))?;
let text = String::from_utf16_lossy(&units);
let trimmed = text.trim();
Ok(parse_iso(trimmed.as_bytes()).or_else(|| parse_text(interp, trimmed)).unwrap_or(f64::NAN))
}
// ---- the constructor ----
pub(crate) fn install(interp: &mut Interp) -> JsResult<()> {
let object_proto = interp.intrinsics.object_proto;
let proto = interp.heap.alloc_object(Object::new(Class::Date(f64::NAN), Some(object_proto)));
interp.intrinsics.date_proto = proto;
let ctor = native(interp, "Date", date_ctor, 7, NativeKind::Plain, true)?;
constructor(interp, "Date", ctor, proto)?;
method(interp, ctor, "parse", date_parse, 1)?;
method(interp, ctor, "UTC", date_utc, 7)?;
method(interp, ctor, "now", date_now, 0)?;
let methods: [(&'static str, NativeFn, u32); 44] = [
("toString", to_string, 0),
("toDateString", to_date_string, 0),
("toTimeString", to_time_string, 0),
("toLocaleString", to_string, 0),
("toLocaleDateString", to_date_string, 0),
("toLocaleTimeString", to_time_string, 0),
("valueOf", get_time, 0),
("getTime", get_time, 0),
("getFullYear", get_full_year, 0),
("getUTCFullYear", get_utc_full_year, 0),
("getMonth", get_month, 0),
("getUTCMonth", get_utc_month, 0),
("getDate", get_date, 0),
("getUTCDate", get_utc_date, 0),
("getDay", get_day, 0),
("getUTCDay", get_utc_day, 0),
("getHours", get_hours, 0),
("getUTCHours", get_utc_hours, 0),
("getMinutes", get_minutes, 0),
("getUTCMinutes", get_utc_minutes, 0),
("getSeconds", get_seconds, 0),
("getUTCSeconds", get_utc_seconds, 0),
("getMilliseconds", get_milliseconds, 0),
("getUTCMilliseconds", get_utc_milliseconds, 0),
("getTimezoneOffset", get_timezone_offset, 0),
("setTime", set_time, 1),
("setMilliseconds", set_milliseconds, 1),
("setUTCMilliseconds", set_utc_milliseconds, 1),
("setSeconds", set_seconds, 2),
("setUTCSeconds", set_utc_seconds, 2),
("setMinutes", set_minutes, 3),
("setUTCMinutes", set_utc_minutes, 3),
("setHours", set_hours, 4),
("setUTCHours", set_utc_hours, 4),
("setDate", set_date, 1),
("setUTCDate", set_utc_date, 1),
("setMonth", set_month, 2),
("setUTCMonth", set_utc_month, 2),
("setFullYear", set_full_year, 3),
("setUTCFullYear", set_utc_full_year, 3),
("toISOString", to_iso_string, 0),
("toJSON", to_json, 1),
("getYear", get_year, 0),
("setYear", set_year, 1),
];
for (name, f, len) in methods {
method(interp, proto, name, f, len)?;
}
// Annex B.2.6: `toGMTString` is the same function object as `toUTCString`.
method(interp, proto, "toUTCString", to_utc_string, 0)?;
let utc = interp.get_value(&Value::Object(proto), &key("toUTCString"))?;
data(interp, proto, "toGMTString", utc, crate::heap::HIDDEN)
}
fn text(interp: &mut Interp, s: &str) -> JsResult<Value> {
reserve_units(interp, s.len())?;
Ok(Value::String(Str::from(s)))
}
/// The numbers of the arguments from `year` on, as `new Date(y, m, …)` and `Date.UTC` read them
/// (sections 15.9.3.1 and 15.9.4.3): the local or UTC date they describe, before TimeClip. An
/// omitted month is January, which `Date.UTC(year)` uses (section 15.9.4.3 leaves a call with
/// fewer than two arguments to the implementation).
fn date_from_parts(interp: &mut Interp, args: &[Value]) -> JsResult<f64> {
let mut n = [f64::NAN, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0];
for (i, slot) in n.iter_mut().enumerate() {
if let Some(v) = args.get(i) {
*slot = interp.to_number(v)?;
}
}
let [y, m, dt, h, min, s, ms] = n;
let yi = to_integer(y);
let yr = if !y.is_nan() && (0.0..=99.0).contains(&yi) { 1900.0 + yi } else { y };
Ok(make_date(make_day(yr, m, dt), make_time(h, min, s, ms)))
}
/// `Date(…)`, `new Date()`, `new Date(value)` and `new Date(y, m, …)` (sections 15.9.2 and
/// 15.9.3).
fn date_ctor(interp: &mut Interp, _: Value, args: &[Value], construct: bool) -> JsResult<Value> {
if !construct {
let s = interp.date_to_string(interp.now());
return text(interp, &s);
}
let tv = match args.len() {
0 => interp.now(),
1 => {
let v = interp.to_primitive(&args[0], Hint::Default)?;
match &v {
Value::String(s) => parse_date(interp, s)?,
_ => time_clip(interp.to_number(&v)?),
}
}
_ => {
let local = date_from_parts(interp, args)?;
time_clip(interp.utc(local))
}
};
let proto = interp.intrinsics.date_proto;
Ok(Value::Object(interp.heap.alloc_object(Object::new(Class::Date(tv), Some(proto)))))
}
fn date_parse(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
let s = interp.to_str(&arg(args, 0))?;
Ok(Value::Number(parse_date(interp, &s)?))
}
fn date_utc(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
let t = date_from_parts(interp, args)?;
Ok(Value::Number(time_clip(t)))
}
fn date_now(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult<Value> {
Ok(Value::Number(interp.now()))
}
// ---- Date.prototype ----
/// The time value of a Date object `this`.
fn this_time(interp: &mut Interp, this: &Value) -> JsResult<f64> {
if let Some(Class::Date(t)) = this.as_object().and_then(|o| interp.heap.object(o)).map(|o| &o.class) {
return Ok(*t);
}
Err(interp.type_error("Date.prototype method called on a value that is not a Date object"))
}
fn set_this_time(interp: &mut Interp, this: &Value, t: f64) -> JsResult<Value> {
if let Some(o) = this.as_object().and_then(|o| interp.heap.object_mut(o))
&& let Class::Date(slot) = &mut o.class
{
*slot = t;
}
Ok(Value::Number(t))
}
fn to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
let t = this_time(interp, &this)?;
let s = interp.date_to_string(t);
text(interp, &s)
}
fn to_date_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
let t = this_time(interp, &this)?;
let s = if t.is_nan() { "Invalid Date".into() } else { date_text(interp.local_time(t)) };
text(interp, &s)
}
fn to_time_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
let t = this_time(interp, &this)?;
let s = if t.is_nan() {
"Invalid Date".into()
} else {
let local = interp.local_time(t);
format!("{} {}", time_text(local), offset_text(local - t))
};
text(interp, &s)
}
fn to_utc_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
let t = this_time(interp, &this)?;
text(interp, &utc_text(t))
}
/// `toISOString` (section 15.9.5.43).
fn to_iso_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
let t = this_time(interp, &this)?;
if !t.is_finite() {
return Err(interp.throw(ErrorType::Range, "invalid time value"));
}
text(interp, &iso_text(t))
}
/// `toJSON` (section 15.9.5.44).
fn to_json(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
let o = interp.to_object(&this)?;
let ov = Value::Object(o);
interp.temp_roots.push(ov.clone());
let r = (|| {
let tv = interp.to_primitive(&ov, Hint::Number)?;
if let Value::Number(n) = tv
&& !n.is_finite()
{
return Ok(Value::Null);
}
let f = interp.get_value(&ov, &key("toISOString"))?;
if !interp.is_callable(&f) {
return Err(interp.type_error("toISOString is not a function"));
}
interp.call(&f, &ov, &[])
})();
interp.temp_roots.pop();
r
}
fn get_time(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
this_time(interp, &this).map(Value::Number)
}
/// A getter: `f` of the time value, in local time when `local`, NaN for an invalid date.
fn getter(interp: &mut Interp, this: &Value, local: bool, f: fn(f64) -> f64) -> JsResult<Value> {
let t = this_time(interp, this)?;
if t.is_nan() {
return Ok(Value::Number(f64::NAN));
}
let t = if local { interp.local_time(t) } else { t };
Ok(Value::Number(f(t)))
}
macro_rules! getters {
($($name:ident, $local:expr, $f:expr;)*) => {
$(fn $name(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
getter(interp, &this, $local, $f)
})*
};
}
getters! {
get_full_year, true, year_from_time;
get_utc_full_year, false, year_from_time;
get_month, true, month_from_time;
get_utc_month, false, month_from_time;
get_date, true, date_from_time;
get_utc_date, false, date_from_time;
get_day, true, week_day;
get_utc_day, false, week_day;
get_hours, true, hour_from_time;
get_utc_hours, false, hour_from_time;
get_minutes, true, min_from_time;
get_utc_minutes, false, min_from_time;
get_seconds, true, sec_from_time;
get_utc_seconds, false, sec_from_time;
get_milliseconds, true, ms_from_time;
get_utc_milliseconds, false, ms_from_time;
}
/// `getYear` (Annex B.2.4).
fn get_year(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
getter(interp, &this, true, |t| year_from_time(t) - 1900.0)
}
fn get_timezone_offset(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
let t = this_time(interp, &this)?;
if t.is_nan() {
return Ok(Value::Number(f64::NAN));
}
Ok(Value::Number((t - interp.local_time(t)) / MS_PER_MINUTE))
}
fn set_time(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
this_time(interp, &this)?;
let t = interp.to_number(&arg(args, 0))?;
set_this_time(interp, &this, time_clip(t))
}
/// The time fields a setter starts at: the setter sets that field and the smaller ones after it
/// from its arguments.
#[derive(Clone, Copy)]
enum TimeField {
Hour = 0,
Minute = 1,
Second = 2,
Ms = 3,
}
/// `setHours`, `setMinutes`, `setSeconds` and `setMilliseconds`, local or UTC (sections
/// 15.9.5.28 to 15.9.5.35).
fn set_time_fields(interp: &mut Interp, this: &Value, args: &[Value], local: bool, first: TimeField, count: usize) -> JsResult<Value> {
let tv = this_time(interp, this)?;
let t = if local { interp.local_time(tv) } else { tv };
let mut fields = [hour_from_time(t), min_from_time(t), sec_from_time(t), ms_from_time(t)];
let first = first as usize;
for k in 0..count {
let given = k == 0 || args.get(k).is_some();
if given {
let v = interp.to_number(&arg(args, k))?;
if let Some(slot) = fields.get_mut(first + k) {
*slot = v;
}
}
}
let [h, m, s, ms] = fields;
let date = make_date(day(t), make_time(h, m, s, ms));
let u = time_clip(if local { interp.utc(date) } else { date });
set_this_time(interp, this, u)
}
/// The date fields a setter starts at.
#[derive(Clone, Copy)]
enum DateField {
Year = 0,
Month = 1,
Date = 2,
}
/// `setFullYear`, `setMonth` and `setDate`, local or UTC (sections 15.9.5.36 to 15.9.5.41).
fn set_date_fields(interp: &mut Interp, this: &Value, args: &[Value], local: bool, first: DateField, count: usize) -> JsResult<Value> {
let tv = this_time(interp, this)?;
let mut t = if local { interp.local_time(tv) } else { tv };
// setFullYear starts from +0 on an invalid date.
if matches!(first, DateField::Year) && t.is_nan() {
t = 0.0;
}
let mut fields = [year_from_time(t), month_from_time(t), date_from_time(t)];
let first = first as usize;
for k in 0..count {
let given = k == 0 || args.get(k).is_some();
if given {
let v = interp.to_number(&arg(args, k))?;
if let Some(slot) = fields.get_mut(first + k) {
*slot = v;
}
}
}
let [y, m, d] = fields;
let date = make_date(make_day(y, m, d), time_within_day(t));
let u = time_clip(if local { interp.utc(date) } else { date });
set_this_time(interp, this, u)
}
macro_rules! setters {
($($name:ident, $f:ident, $local:expr, $field:expr, $count:expr;)*) => {
$(fn $name(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
$f(interp, &this, args, $local, $field, $count)
})*
};
}
setters! {
set_milliseconds, set_time_fields, true, TimeField::Ms, 1;
set_utc_milliseconds, set_time_fields, false, TimeField::Ms, 1;
set_seconds, set_time_fields, true, TimeField::Second, 2;
set_utc_seconds, set_time_fields, false, TimeField::Second, 2;
set_minutes, set_time_fields, true, TimeField::Minute, 3;
set_utc_minutes, set_time_fields, false, TimeField::Minute, 3;
set_hours, set_time_fields, true, TimeField::Hour, 4;
set_utc_hours, set_time_fields, false, TimeField::Hour, 4;
set_date, set_date_fields, true, DateField::Date, 1;
set_utc_date, set_date_fields, false, DateField::Date, 1;
set_month, set_date_fields, true, DateField::Month, 2;
set_utc_month, set_date_fields, false, DateField::Month, 2;
set_full_year, set_date_fields, true, DateField::Year, 3;
set_utc_full_year, set_date_fields, false, DateField::Year, 3;
}
/// `setYear` (Annex B.2.5).
fn set_year(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
let tv = this_time(interp, &this)?;
let t = if tv.is_nan() { 0.0 } else { interp.local_time(tv) };
let y = interp.to_number(&arg(args, 0))?;
if y.is_nan() {
return set_this_time(interp, &this, f64::NAN);
}
let yi = to_integer(y);
let yr = if (0.0..=99.0).contains(&yi) { 1900.0 + yi } else { y };
let d = make_day(yr, month_from_time(t), date_from_time(t));
let u = time_clip(interp.utc(make_date(d, time_within_day(t))));
set_this_time(interp, &this, u)
}
#[cfg(test)]
mod tests {
use super::{date_from_time, first_day_of_month, iso_text, make_date, make_day, make_time, month_from_time, parse_iso, week_day, year_from_time};
#[test]
fn time_values_split_into_fields_and_back() {
// 2000-02-29 is a leap day; 1969-12-31 is before the epoch.
let cases: &[(f64, f64, f64, f64)] = &[(2000.0, 1.0, 29.0, 2.0), (1969.0, 11.0, 31.0, 3.0), (1970.0, 0.0, 1.0, 4.0), (-1.0, 0.0, 1.0, 5.0)];
for &(y, m, d, wd) in cases {
let t = make_date(make_day(y, m, d), make_time(13.0, 5.0, 7.0, 9.0));
assert_eq!((year_from_time(t), month_from_time(t), date_from_time(t), week_day(t)), (y, m, d, wd), "{y}-{m}-{d}");
}
assert_eq!(first_day_of_month(1970.0, 0), Some(0.0));
assert_eq!(make_day(2001.0, -1.0, 1.0), make_day(2000.0, 11.0, 1.0));
// The ends of the time value range lie past chrono's years; whole 400-year cycles reach them.
assert_eq!(make_day(275_760.0, 8.0, 13.0), 1e8);
assert_eq!(make_day(-271_821.0, 3.0, 20.0), -1e8);
assert_eq!((year_from_time(8.64e15), month_from_time(8.64e15), date_from_time(8.64e15)), (275_760.0, 8.0, 13.0));
assert_eq!((year_from_time(-8.64e15), month_from_time(-8.64e15), date_from_time(-8.64e15)), (-271_821.0, 3.0, 20.0));
assert_eq!(make_day(1600.0, 1.0, 29.0) + 1.0, make_day(1600.0, 2.0, 1.0));
assert_eq!(make_day(1900.0, 1.0, 29.0), make_day(1900.0, 2.0, 1.0));
}
#[test]
fn the_iso_format_reads_back_what_to_iso_string_writes() {
for t in [0.0, 951_782_400_123.0, -62_198_755_200_000.0, 8.64e15, -8.64e15] {
let text = iso_text(t);
assert_eq!(parse_iso(text.as_bytes()), Some(t), "{text}");
}
assert_eq!(parse_iso(b"1970"), Some(0.0));
assert_eq!(parse_iso(b"1970-01-01T00:00+01:00"), Some(-3_600_000.0));
assert_eq!(parse_iso(b"1970-01-01T24:00:00Z"), Some(86_400_000.0));
for bad in ["1970-13-01", "1970-02-30", "1970-01-01T24:00:01", "-000000-01-01", "1970-1-1", "1970-01-01T10", "1970-01-01 10:00"] {
assert_eq!(parse_iso(bad.as_bytes()), None, "{bad}");
}
}
}
+13 -5
View File
@@ -206,10 +206,12 @@ pub(crate) enum Class {
Arguments(Box<ArgMap>),
/// A `Boolean` object made by ToObject.
Boolean,
/// A `Number` object made by ToObject.
Number,
/// A `Number` object and its number.
Number(f64),
String(Str),
RegExp,
RegExp(Rc<crate::regexp_vm::Regex>),
/// A `Date` object and its time value.
Date(f64),
ForIn(Box<ForIn>),
}
@@ -223,9 +225,10 @@ impl Class {
Class::Error => "Error",
Class::Arguments(_) => "Arguments",
Class::Boolean => "Boolean",
Class::Number => "Number",
Class::Number(_) => "Number",
Class::String(_) => "String",
Class::RegExp => "RegExp",
Class::RegExp(_) => "RegExp",
Class::Date(_) => "Date",
}
}
}
@@ -528,6 +531,11 @@ fn trace_object(o: &Object, work: &mut Vec<u32>, strings: &mut StringSizes) -> u
}
Class::Arguments(map) => work.push(map.env.0),
Class::String(s) => strings.add(s),
Class::RegExp(re) => {
if strings.seen.insert(Rc::as_ptr(re) as *const () as usize) {
strings.bytes += re.heap_bytes();
}
}
Class::ForIn(state) => {
work.extend(state.object.iter().map(|o| o.0));
strings.bytes += state.keys.iter().map(|k| string_bytes(k.len())).sum::<usize>();
+84 -14
View File
@@ -68,6 +68,7 @@ pub(crate) struct Intrinsics {
pub(crate) number_proto: ObjId,
pub(crate) boolean_proto: ObjId,
pub(crate) regexp_proto: ObjId,
pub(crate) date_proto: ObjId,
pub(crate) error_protos: [ObjId; 7],
pub(crate) eval: ObjId,
pub(crate) throw_type_error: ObjId,
@@ -84,6 +85,7 @@ impl Intrinsics {
number_proto: o,
boolean_proto: o,
regexp_proto: o,
date_proto: o,
error_protos: [o; 7],
eval: o,
throw_type_error: o,
@@ -112,6 +114,7 @@ impl Intrinsics {
self.number_proto,
self.boolean_proto,
self.regexp_proto,
self.date_proto,
self.eval,
self.throw_type_error,
];
@@ -161,6 +164,56 @@ pub(crate) struct Interp {
pub(crate) throw_site: Option<ThrowSite>,
/// The last exception a run did not catch.
pub(crate) last_thrown: Value,
/// The clock and time zone `Date` reads.
pub(crate) date_host: crate::date_builtins::DateHost,
/// The bytes native code holds outside the heap's cells through [`HeldBytes`].
pub(crate) held: Rc<HeldCount>,
}
/// The bytes all live [`HeldBytes`] hold, and the bytes dropped holders released that the heap's
/// total still counts.
#[derive(Default)]
pub(crate) struct HeldCount {
now: std::cell::Cell<usize>,
released: std::cell::Cell<usize>,
}
/// Bytes that native code holds in Rust memory while it works (a matcher's state and stack, a
/// replacement being built, a pattern being compiled), counted against the heap limit. They are
/// in the heap's total, and in the live total of each collection, until the holder is dropped;
/// the heap's total gives them back before the next limit check.
pub(crate) struct HeldBytes {
count: Rc<HeldCount>,
bytes: usize,
}
impl HeldBytes {
pub(crate) fn new(interp: &Interp) -> Self {
Self { count: interp.held.clone(), bytes: 0 }
}
/// The bytes this holder holds.
#[cfg(test)]
pub(crate) fn bytes(&self) -> usize {
self.bytes
}
/// Holds `n` more bytes, ending the run with the heap limit when they would not fit after a
/// collection. Everything the caller still needs must be reachable from the roots.
pub(crate) fn add(&mut self, interp: &mut Interp, n: usize) -> JsResult<()> {
interp.reserve_heap(n)?;
interp.heap.bytes = interp.heap.bytes.saturating_add(n);
self.bytes = self.bytes.saturating_add(n);
self.count.now.set(self.count.now.get().saturating_add(n));
Ok(())
}
}
impl Drop for HeldBytes {
fn drop(&mut self) {
self.count.now.set(self.count.now.get().saturating_sub(self.bytes));
self.count.released.set(self.count.released.get().saturating_add(self.bytes));
}
}
/// The least heap size a collection waits for.
@@ -277,8 +330,10 @@ impl Interp {
global,
global_env,
intrinsics: Intrinsics::placeholder(),
fuel: limits.fuel,
limits,
fuel: u64::MAX,
// The built-ins are installed without limits; the realm's limits apply from the
// first run.
limits: Limits { fuel: u64::MAX, heap_bytes: usize::MAX, frames: usize::MAX, reentry: usize::MAX },
options,
interrupt: Arc::new(AtomicBool::new(false)),
reentry: 0,
@@ -286,8 +341,12 @@ impl Interp {
source_units: 0,
throw_site: None,
last_thrown: Value::Undefined,
date_host: crate::date_builtins::DateHost::default(),
held: Rc::default(),
};
crate::builtins::install(&mut interp);
interp.fuel = limits.fuel;
interp.limits = limits;
interp.gc_threshold = interp.next_threshold();
interp
}
@@ -388,6 +447,7 @@ impl Interp {
fn tick(&mut self) -> JsResult<()> {
self.use_fuel()?;
self.settle_held();
if self.heap.bytes > self.gc_threshold {
self.collect_garbage();
if self.heap.bytes > self.limits.heap_bytes {
@@ -401,6 +461,7 @@ impl Interp {
/// Ends the run with the heap limit when `bytes` more would not fit in it after a
/// collection. Everything the caller still needs must be reachable from the roots.
pub(crate) fn reserve_heap(&mut self, bytes: usize) -> JsResult<()> {
self.settle_held();
if self.heap.bytes.saturating_add(bytes) <= self.limits.heap_bytes {
return Ok(());
}
@@ -412,6 +473,19 @@ impl Interp {
Ok(())
}
/// Takes the bytes that dropped [`HeldBytes`] released off the heap's total.
fn settle_held(&mut self) {
let released = self.held.released.replace(0);
self.heap.bytes = self.heap.bytes.saturating_sub(released);
}
/// Collects garbage now and sets when the next collection runs.
#[cfg(test)]
pub(crate) fn collect_for_room(&mut self) {
self.collect_garbage();
self.gc_threshold = self.next_threshold();
}
/// Charges code compiled at run time to the heap, reserving its estimated size and `extra`
/// more first. The code is not reachable from the roots yet, so a collection here leaves it
/// out of the live total and the charge adds it.
@@ -439,6 +513,9 @@ impl Interp {
roots.objects.extend(self.intrinsics.all());
roots.envs.push(self.global_env);
self.heap.collect(&roots);
// The collection counts the live total again, so bytes already released are not in it.
self.held.released.set(0);
self.heap.bytes = self.heap.bytes.saturating_add(self.held.now.get());
}
/// Runs frames until the boundary frame on top returns, and returns its value.
@@ -1334,16 +1411,7 @@ impl Interp {
fn op_regexp(&mut self, i: u32) -> JsResult<()> {
let Some((source, flags)) = self.frame()?.code.regexps.get(i as usize).cloned() else { return internal("regular expression out of range") };
let proto = self.intrinsics.regexp_proto;
let r = self.heap.alloc_object(Object::new(Class::RegExp, Some(proto)));
let flag_units = flags.flat();
let has = |c: u8| flag_units.contains(&u16::from(c));
let (g, i_, m) = (has(b'g'), has(b'i'), has(b'm'));
self.define(r, key("source"), Prop::Data { value: Value::String(source), flags: 0 })?;
self.define(r, key("global"), Prop::Data { value: Value::Bool(g), flags: 0 })?;
self.define(r, key("ignoreCase"), Prop::Data { value: Value::Bool(i_), flags: 0 })?;
self.define(r, key("multiline"), Prop::Data { value: Value::Bool(m), flags: 0 })?;
self.define(r, key("lastIndex"), Prop::Data { value: Value::Number(0.0), flags: WRITABLE })?;
let r = crate::regexp_builtins::new_regexp(self, &source, &flags)?;
self.push(Value::Object(r))
}
@@ -1938,7 +2006,9 @@ impl Interp {
#[allow(clippy::wrong_self_convention)]
pub(crate) fn to_primitive(&mut self, v: &Value, hint: Hint) -> JsResult<Value> {
let Value::Object(o) = v else { return Ok(v.clone()) };
let order = if hint == Hint::String { ["toString", "valueOf"] } else { ["valueOf", "toString"] };
// Without a hint, a Date object converts as with the String hint.
let date = hint == Hint::Default && matches!(self.heap.object(*o).map(|ob| &ob.class), Some(Class::Date(_)));
let order = if hint == Hint::String || date { ["toString", "valueOf"] } else { ["valueOf", "toString"] };
for name in order {
let f = self.get_from(*o, &key(name), v)?;
if self.is_callable(&f) {
@@ -2012,7 +2082,7 @@ impl Interp {
Value::Object(o) => return Ok(*o),
Value::Undefined | Value::Null => return Err(self.type_error("cannot convert undefined or null to an object")),
Value::Bool(_) => (Class::Boolean, self.intrinsics.boolean_proto),
Value::Number(_) => (Class::Number, self.intrinsics.number_proto),
Value::Number(n) => (Class::Number(*n), self.intrinsics.number_proto),
Value::String(s) => (Class::String(s.clone()), self.intrinsics.string_proto),
};
Ok(self.heap.alloc_object(Object::new(class, Some(proto))))
+14 -3
View File
@@ -23,9 +23,13 @@
//! verifier, and runs it on an interpreter that keeps JavaScript calls on its own frame stack, so
//! deep recursion in a script does not use the native stack. Objects and environments live in an
//! arena with a mark-and-sweep collector; strings built by repeated `+` are flattened and dropped
//! with loops. The realm provides the language (sections 10 to 14 of ECMA-262 5.1) and a minimal
//! set of built-ins: `Object` and `Object.prototype`'s basic methods, `Function` with
//! `Function.prototype.call` and `apply`, `eval`, and the error constructors. Every run is bounded
//! with loops. The realm provides the language (sections 10 to 14 of ECMA-262 5.1) and these
//! built-ins: `Object` and `Object.prototype`'s basic methods, `Function` with
//! `Function.prototype.call` and `apply`, `eval`, the error constructors, and `String`, `Number`,
//! `RegExp` and `Date` (sections 15.5, 15.7, 15.9 and 15.10). Regular expressions run on a
//! backtracking matcher that keeps its choice points on its own stack and takes fuel for every
//! step; `Date` reads the clock and time zone the host sets with [`Realm::set_clock`] and
//! [`Realm::set_time_zone`]. Every run is bounded
//! by [`Limits`]: instructions run (fuel), heap bytes, frames, nested host calls, and an
//! [`Interrupt`] flag another thread can set. A run that reaches a limit ends with
//! [`RunErrorKind::Limit`], which a script's `try` cannot catch.
@@ -36,14 +40,19 @@ mod builtins;
mod bytecode;
mod chars;
mod compiler;
mod date_builtins;
mod error;
mod heap;
mod interp;
mod lexer;
mod number;
mod number_builtins;
mod parser;
mod realm;
mod regexp;
mod regexp_builtins;
mod regexp_vm;
mod string_builtins;
mod token;
mod value;
@@ -173,3 +182,5 @@ impl LineTable {
mod interp_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod text_tests;
+306 -2
View File
@@ -1,4 +1,7 @@
//! Number to string conversion (ECMA-262 5.1 section 9.8.1), for numeric property names.
//! Number to string conversion (ECMA-262 5.1 section 9.8.1), for numeric property names, and the
//! formats of `Number.prototype`'s `toString(radix)`, `toFixed`, `toExponential` and
//! `toPrecision` (section 15.7.4). The fixed, exponential and precision formats round the
//! number's exact decimal value, ties away from zero, as the specification's "larger n" says.
/// The string ToString gives for the number `x`: the shortest decimal digits that read back as
/// `x`, laid out as section 9.8.1 says.
@@ -38,9 +41,310 @@ pub fn to_string(x: f64) -> String {
}
}
/// The exact decimal digits of a positive finite `x`, without trailing zeros, and the position of
/// the decimal point: `x = 0.d1d2… × 10^point`.
fn exact_digits(x: f64) -> (Vec<u8>, i64) {
// A double's exact decimal expansion has at most 767 significant digits.
let sci = format!("{x:.800e}");
let (mantissa, exp) = sci.split_once('e').unwrap_or((&sci, "0"));
let mut digits: Vec<u8> = mantissa.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect();
while digits.len() > 1 && digits.last() == Some(&0) {
digits.pop();
}
(digits, exp.parse::<i64>().unwrap_or(0) + 1)
}
/// The shortest digits that read back as a positive finite `x`, and the decimal point's position.
fn shortest_digits(x: f64) -> (Vec<u8>, i64) {
let sci = format!("{x:e}");
let (mantissa, exp) = sci.split_once('e').unwrap_or((&sci, "0"));
(mantissa.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect(), exp.parse::<i64>().unwrap_or(0) + 1)
}
/// Rounds `digits` (a number `0.d1d2… × 10^point`) to its first `keep` digits, a half rounding
/// up. Returns exactly `keep` digits and the new point; `keep` may be 0 or negative, which leaves
/// either nothing or a single `1` one place up.
fn round_digits(digits: &[u8], point: i64, keep: i64) -> (Vec<u8>, i64) {
if keep < 0 {
return (Vec::new(), point);
}
let k = usize::try_from(keep).unwrap_or(usize::MAX);
if digits.len() <= k {
let mut out = digits.to_vec();
out.resize(k, 0);
return (out, point);
}
let mut out: Vec<u8> = digits.get(..k).unwrap_or_default().to_vec();
if digits.get(k).is_some_and(|&d| d >= 5) {
let mut i = out.len();
loop {
if i == 0 {
out.insert(0, 1);
out.truncate(k.max(1));
return (out, point + 1);
}
i -= 1;
match out.get_mut(i) {
Some(d) if *d == 9 => *d = 0,
Some(d) => {
*d += 1;
break;
}
None => break,
}
}
}
(out, point)
}
fn digits_text(digits: &[u8]) -> String {
digits.iter().map(|d| char::from(b'0' + d)).collect()
}
/// `Number.prototype.toFixed` (section 15.7.4.5) for `f` digits after the point, 0 to 20.
pub(crate) fn to_fixed(x: f64, f: usize) -> String {
if x.is_nan() {
return "NaN".into();
}
if x.abs() >= 1e21 {
return to_string(x);
}
let sign = if x < 0.0 { "-" } else { "" };
let x = x.abs();
let fi = i64::try_from(f).unwrap_or(0);
let n = if x == 0.0 {
vec![0]
} else {
let (digits, point) = exact_digits(x);
let (mut n, new_point) = round_digits(&digits, point, point + fi);
if n.is_empty() {
n.push(0);
} else if new_point > point {
// A carry past the first digit leaves one digit more than kept.
n.push(0);
n.truncate(usize::try_from(new_point + fi).unwrap_or(n.len()));
}
n
};
let mut m = digits_text(&n);
let m_trim = m.trim_start_matches('0');
m = if m_trim.is_empty() { "0".into() } else { m_trim.into() };
if f == 0 {
return format!("{sign}{m}");
}
if m.len() <= f {
m = format!("{}{m}", "0".repeat(f + 1 - m.len()));
}
let (int, frac) = m.split_at(m.len() - f);
format!("{sign}{int}.{frac}")
}
/// `Number.prototype.toExponential` (section 15.7.4.6) for `f` digits after the point (0 to 20),
/// or as many as the number needs when `f` is `None`.
pub(crate) fn to_exponential(x: f64, f: Option<usize>) -> String {
if !x.is_finite() {
return to_string(x);
}
let sign = if x < 0.0 { "-" } else { "" };
let x = x.abs();
let (digits, e) = if x == 0.0 {
(vec![0; f.unwrap_or(0) + 1], 0)
} else {
match f {
None => {
let (d, point) = shortest_digits(x);
(d, point - 1)
}
Some(f) => {
let (d, point) = exact_digits(x);
let (d, point) = round_digits(&d, point, i64::try_from(f).unwrap_or(0) + 1);
(d, point - 1)
}
}
};
let text = digits_text(&digits);
let (first, rest) = text.split_at(1.min(text.len()));
let mantissa = if rest.is_empty() { first.to_string() } else { format!("{first}.{rest}") };
let esign = if e < 0 { '-' } else { '+' };
format!("{sign}{mantissa}e{esign}{}", e.abs())
}
/// `Number.prototype.toPrecision` (section 15.7.4.7) for `p` significant digits, 1 to 21.
pub(crate) fn to_precision(x: f64, p: usize) -> String {
if !x.is_finite() {
return to_string(x);
}
let sign = if x < 0.0 { "-" } else { "" };
let x = x.abs();
let pi = i64::try_from(p).unwrap_or(1);
let (digits, e) = if x == 0.0 {
(vec![0; p], 0)
} else {
let (d, point) = exact_digits(x);
let (d, point) = round_digits(&d, point, pi);
(d, point - 1)
};
let m = digits_text(&digits);
if e < -6 || e >= pi {
let (first, rest) = m.split_at(1.min(m.len()));
let mantissa = if rest.is_empty() { first.to_string() } else { format!("{first}.{rest}") };
let esign = if e < 0 { '-' } else { '+' };
return format!("{sign}{mantissa}e{esign}{}", e.abs());
}
if e == pi - 1 {
return format!("{sign}{m}");
}
if e >= 0 {
let (int, frac) = m.split_at(usize::try_from(e + 1).unwrap_or(0).min(m.len()));
return format!("{sign}{int}.{frac}");
}
format!("{sign}0.{}{m}", "0".repeat(usize::try_from(-(e + 1)).unwrap_or(0)))
}
/// `Number.prototype.toString(radix)` (section 15.7.4.2) for a radix of 2 to 36 other than 10:
/// the integer part exactly, and fraction digits until the number would read back unchanged.
pub(crate) fn to_radix_string(x: f64, radix: u32) -> String {
if !x.is_finite() || x == 0.0 {
return to_string(x);
}
const CHARS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz";
let r = f64::from(radix);
let sign = if x < 0.0 { "-" } else { "" };
let x = x.abs();
let mut integer = x.floor();
let mut fraction = x - integer;
// Half the distance to the next double: digits past this resolution are not needed.
let next = f64::from_bits(x.to_bits() + 1);
let mut delta = (0.5 * (next - x)).max(f64::from_bits(1));
let mut frac_digits: Vec<u8> = Vec::new();
if fraction >= delta {
loop {
fraction *= r;
delta *= r;
let digit = fraction.floor();
frac_digits.push(digit as u8);
fraction -= digit;
if (fraction > 0.5 || (fraction == 0.5 && (digit as u8) & 1 == 1)) && fraction + delta > 1.0 {
// Round up, carrying into the integer part when every digit is the largest.
loop {
match frac_digits.last_mut() {
None => {
integer += 1.0;
break;
}
Some(d) if u32::from(*d) + 1 == radix => {
frac_digits.pop();
}
Some(d) => {
*d += 1;
break;
}
}
}
break;
}
if fraction < delta {
break;
}
}
}
let mut int_digits: Vec<u8> = Vec::new();
// Digits below the double's precision are zeros.
while (integer / r) >= 9_007_199_254_740_992.0 {
integer /= r;
int_digits.push(0);
}
loop {
let rem = integer % r;
int_digits.push(rem as u8);
integer = (integer - rem) / r;
if integer <= 0.0 {
break;
}
}
let mut out = String::from(sign);
out.extend(int_digits.iter().rev().map(|&d| char::from(CHARS.get(usize::from(d)).copied().unwrap_or(b'0'))));
if !frac_digits.is_empty() {
out.push('.');
out.extend(frac_digits.iter().map(|&d| char::from(CHARS.get(usize::from(d)).copied().unwrap_or(b'0'))));
}
out
}
#[cfg(test)]
mod tests {
use super::to_string;
use super::{to_exponential, to_fixed, to_precision, to_radix_string, to_string};
#[test]
fn fixed_exponential_and_precision_round_the_exact_value_with_ties_away_from_zero() {
let fixed: &[(f64, usize, &str)] = &[
(0.5, 0, "1"),
(2.5, 0, "3"),
(1.45, 1, "1.4"),
(1.255, 2, "1.25"),
(1.005, 2, "1.00"),
(0.1, 20, "0.10000000000000000555"),
(123.456, 2, "123.46"),
(-1.5, 0, "-2"),
(-0.0000001, 2, "-0.00"),
(-0.0, 2, "0.00"),
(0.0, 0, "0"),
(99.995, 2, "100.00"),
(9.5, 0, "10"),
(1e21, 2, "1e+21"),
(1000000000000000128.0, 0, "1000000000000000128"),
(f64::NAN, 2, "NaN"),
];
for (x, f, want) in fixed {
assert_eq!(to_fixed(*x, *f), *want, "({x}).toFixed({f})");
}
let exp: &[(f64, Option<usize>, &str)] = &[
(123456.0, Some(2), "1.23e+5"),
(123456.0, None, "1.23456e+5"),
(0.00015, Some(1), "1.5e-4"),
(1.25, Some(1), "1.3e+0"),
(0.0, Some(2), "0.00e+0"),
(0.0, None, "0e+0"),
(-6.9e-11, Some(1), "-6.9e-11"),
(9.99, Some(1), "1.0e+1"),
(f64::INFINITY, Some(2), "Infinity"),
];
for (x, f, want) in exp {
assert_eq!(to_exponential(*x, *f), *want, "({x}).toExponential({f:?})");
}
let prec: &[(f64, usize, &str)] = &[
(123.456, 4, "123.5"),
(0.000123, 2, "0.00012"),
(0.0000001234, 2, "1.2e-7"),
(123456.0, 2, "1.2e+5"),
(123456.0, 6, "123456"),
(0.0, 3, "0.00"),
(1.0, 1, "1"),
(99.5, 2, "1.0e+2"),
(1e21, 21, "1.00000000000000000000e+21"),
];
for (x, p, want) in prec {
assert_eq!(to_precision(*x, *p), *want, "({x}).toPrecision({p})");
}
}
#[test]
fn radix_strings_give_exact_integers_and_enough_fraction_digits() {
let cases: &[(f64, u32, &str)] = &[
(255.0, 16, "ff"),
(255.0, 2, "11111111"),
(-255.0, 36, "-73"),
(0.5, 2, "0.1"),
(0.5, 16, "0.8"),
(3.75, 2, "11.11"),
(2f64.powi(60), 16, "1000000000000000"),
(1e21, 36, "5v1j4f4ds7c000"),
(0.1, 3, "0.0022002200220022002200220022002201"),
];
for (x, r, want) in cases {
assert_eq!(to_radix_string(*x, *r), *want, "({x}).toString({r})");
}
}
#[test]
fn numbers_print_as_section_9_8_1_lays_them_out() {
+127
View File
@@ -0,0 +1,127 @@
//! `Number` (ECMA-262 5.1 section 15.7): the constructor, its constants and the methods of
//! `Number.prototype`. The formats are in [`crate::number`].
use crate::builtins::{arg, constructor, data, method, native};
use crate::heap::{Class, NativeKind, Object};
use crate::interp::{ErrorType, Interp, JsResult};
use crate::number;
use crate::string_builtins::{reserve_units, to_integer};
use crate::value::{Str, Value};
pub(crate) fn install(interp: &mut Interp) -> JsResult<()> {
let proto = interp.intrinsics.number_proto;
let ctor = native(interp, "Number", number_ctor, 1, NativeKind::Plain, true)?;
constructor(interp, "Number", ctor, proto)?;
let constants = [
("MAX_VALUE", f64::MAX),
("MIN_VALUE", f64::from_bits(1)),
("NaN", f64::NAN),
("NEGATIVE_INFINITY", f64::NEG_INFINITY),
("POSITIVE_INFINITY", f64::INFINITY),
];
for (name, v) in constants {
data(interp, ctor, name, Value::Number(v), 0)?;
}
method(interp, proto, "toString", to_string, 1)?;
method(interp, proto, "toLocaleString", to_locale_string, 0)?;
method(interp, proto, "valueOf", value_of, 0)?;
method(interp, proto, "toFixed", to_fixed, 1)?;
method(interp, proto, "toExponential", to_exponential, 1)?;
method(interp, proto, "toPrecision", to_precision, 1)
}
/// `Number(value)` and `new Number(value)` (sections 15.7.1 and 15.7.2).
fn number_ctor(interp: &mut Interp, _: Value, args: &[Value], construct: bool) -> JsResult<Value> {
let n = match args.first() {
Some(v) => interp.to_number(v)?,
None => 0.0,
};
if !construct {
return Ok(Value::Number(n));
}
let proto = interp.intrinsics.number_proto;
Ok(Value::Object(interp.heap.alloc_object(Object::new(Class::Number(n), Some(proto)))))
}
/// The number a `Number.prototype` method works on: a number, or a Number object's number.
fn this_number(interp: &mut Interp, this: &Value, name: &str) -> JsResult<f64> {
match this {
Value::Number(n) => Ok(*n),
Value::Object(o) => match interp.heap.object(*o).map(|o| &o.class) {
Some(Class::Number(n)) => Ok(*n),
_ => Err(interp.type_error(&format!("Number.prototype.{name} was called on a value that is not a number"))),
},
_ => Err(interp.type_error(&format!("Number.prototype.{name} was called on a value that is not a number"))),
}
}
fn text(interp: &mut Interp, s: &str) -> JsResult<Value> {
reserve_units(interp, s.len())?;
Ok(Value::String(Str::from(s)))
}
/// `toString(radix)` (section 15.7.4.2).
fn to_string(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
let x = this_number(interp, &this, "toString")?;
let radix = match arg(args, 0) {
Value::Undefined => 10.0,
r => to_integer(interp.to_number(&r)?),
};
if !(2.0..=36.0).contains(&radix) {
return Err(interp.throw(ErrorType::Range, "the radix must be between 2 and 36"));
}
let s = if radix == 10.0 { number::to_string(x) } else { number::to_radix_string(x, radix as u32) };
text(interp, &s)
}
fn to_locale_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
let x = this_number(interp, &this, "toLocaleString")?;
text(interp, &number::to_string(x))
}
fn value_of(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
this_number(interp, &this, "valueOf").map(Value::Number)
}
/// `toFixed(fractionDigits)` (section 15.7.4.5).
fn to_fixed(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
let x = this_number(interp, &this, "toFixed")?;
let f = to_integer(interp.to_number(&arg(args, 0))?);
if !(0.0..=20.0).contains(&f) {
return Err(interp.throw(ErrorType::Range, "toFixed() digits must be between 0 and 20"));
}
text(interp, &number::to_fixed(x, f as usize))
}
/// `toExponential(fractionDigits)` (section 15.7.4.6).
fn to_exponential(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
let x = this_number(interp, &this, "toExponential")?;
let digits = arg(args, 0);
let f = to_integer(interp.to_number(&digits)?);
if !x.is_finite() {
return text(interp, &number::to_string(x));
}
let f = match digits {
Value::Undefined => None,
_ if !(0.0..=20.0).contains(&f) => return Err(interp.throw(ErrorType::Range, "toExponential() digits must be between 0 and 20")),
_ => Some(f as usize),
};
text(interp, &number::to_exponential(x, f))
}
/// `toPrecision(precision)` (section 15.7.4.7).
fn to_precision(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
let x = this_number(interp, &this, "toPrecision")?;
let precision = arg(args, 0);
if matches!(precision, Value::Undefined) {
return text(interp, &number::to_string(x));
}
let p = to_integer(interp.to_number(&precision)?);
if !x.is_finite() {
return text(interp, &number::to_string(x));
}
if !(1.0..=21.0).contains(&p) {
return Err(interp.throw(ErrorType::Range, "toPrecision() argument must be between 1 and 21"));
}
text(interp, &number::to_precision(x, p as usize))
}
+15
View File
@@ -209,6 +209,21 @@ impl Realm {
let _ = interp.define(global, key(name), Prop::Data { value: v, flags: HIDDEN });
}
/// Sets the clock `Date.now()` and `new Date()` read: the current time in milliseconds since
/// 1970-01-01 UTC. By default it is the system clock; a wasm build has none, and its default
/// gives NaN.
pub fn set_clock(&mut self, clock: impl Fn() -> f64 + 'static) {
self.interp.date_host.clock = std::rc::Rc::new(clock);
}
/// Sets the time zone `Date` uses for local time: `offset` gives local time's offset from UTC
/// in milliseconds at a time value (milliseconds since 1970-01-01 UTC), so it can follow
/// daylight saving time. An offset that is not finite or is a day or more counts as 0. By
/// default local time is UTC.
pub fn set_time_zone(&mut self, offset: impl Fn(f64) -> f64 + 'static) {
self.interp.date_host.offset = std::rc::Rc::new(offset);
}
/// Collects garbage now and returns the heap's estimated size in bytes afterwards.
pub fn collect_garbage(&mut self) -> usize {
self.interp.collect_garbage();
+2 -9
View File
@@ -176,15 +176,8 @@ fn class_atom(pattern: &[u16], i: usize) -> Result<(Option<u32>, usize), String>
_ => (Some(u32::from(b'\\')), i + 1),
},
'0'..='7' => {
let mut value = 0;
let mut j = i + 1;
while j < i + 4
&& let Some(d) = pattern.get(j).and_then(|&u| char::from_u32(u32::from(u))).and_then(|ch| ch.to_digit(8))
{
value = value * 8 + d;
j += 1;
}
(Some(value), j)
let (value, end) = crate::regexp_vm::octal(pattern, i + 1);
(Some(u32::from(value)), end)
}
_ => (Some(next), i + 2),
})
+275
View File
@@ -0,0 +1,275 @@
//! `RegExp` (ECMA-262 5.1 section 15.10.3 to 15.10.7): the constructor, regular expression
//! literals' objects, and `exec`, `test` and `toString`. Matching is in [`crate::regexp_vm`].
use std::rc::Rc;
use crate::builtins::{arg, constructor, method, native};
use crate::heap::{Class, NativeKind, OBJECT_BYTES, OPEN, ObjId, Object, PROP_BYTES, Prop, WRITABLE, key, string_bytes};
use crate::interp::{ErrorType, HeldBytes, Interp, JsResult};
use crate::regexp_vm::{CompileFail, Cost, Flags, Matcher, Regex, compile, parse_flags, use_fuel_n};
use crate::string_builtins::{flat_charged, new_array, reserve_units, rooted, set_element, substring_value, to_integer};
use crate::value::{Str, Value};
pub(crate) fn install(interp: &mut Interp) -> JsResult<()> {
let proto = interp.intrinsics.regexp_proto;
// The prototype is itself a regular expression object, matching the empty string.
let empty = compile_regex(interp, &[], &[], 0)?;
if let Some(p) = interp.heap.object_mut(proto) {
p.class = Class::RegExp(empty);
}
define_flags(interp, proto, Str::from("(?:)"), false, false, false)?;
let ctor = native(interp, "RegExp", regexp_ctor, 2, NativeKind::Plain, true)?;
constructor(interp, "RegExp", ctor, proto)?;
method(interp, proto, "exec", exec, 1)?;
method(interp, proto, "test", test, 1)?;
method(interp, proto, "toString", to_string, 0)
}
/// The object and the compiled form of a RegExp object.
pub(crate) fn regexp_of(interp: &Interp, v: &Value) -> Option<(ObjId, Rc<Regex>)> {
let o = v.as_object()?;
match &interp.heap.object(o)?.class {
Class::RegExp(re) => Some((o, re.clone())),
_ => None,
}
}
/// Checks and compiles a pattern, throwing a `SyntaxError` for a pattern or flags that are not
/// valid. The check takes one unit of fuel for each unit of the pattern. What compiling holds is held against the heap limit as it grows. The compiled form's
/// bytes are then reserved together with `extra` more and added to the heap, so a caller that
/// stores the result in an object it allocates next reserves that object here.
fn compile_regex(interp: &mut Interp, pattern: &[u16], flags: &[u16], extra: usize) -> JsResult<Rc<Regex>> {
let flags: Flags = match parse_flags(flags) {
Ok(f) => f,
Err(m) => return Err(interp.throw(ErrorType::Syntax, &m)),
};
let depth = interp.options.max_depth;
use_fuel_n(interp, pattern.len() as u64)?;
if let Err(m) = crate::regexp::check(pattern, depth) {
return Err(interp.throw(ErrorType::Syntax, &format!("invalid regular expression: {m}")));
}
let mut held = HeldBytes::new(interp);
let compiled = compile(pattern, flags, depth, &mut |cost| match cost {
Cost::Fuel(n) => use_fuel_n(interp, n),
Cost::Heap(n) => held.add(interp, n),
});
drop(held);
let re = match compiled {
Ok(re) => re,
Err(CompileFail::Syntax(m)) => return Err(interp.throw(ErrorType::Syntax, &format!("invalid regular expression: {m}"))),
Err(CompileFail::Abrupt(a)) => return Err(a),
};
let bytes = re.heap_bytes();
interp.reserve_heap(bytes.saturating_add(extra))?;
interp.heap.bytes = interp.heap.bytes.saturating_add(bytes);
Ok(Rc::new(re))
}
/// A new RegExp object whose `source` property is `source` (section 15.10.4.1, steps 7 to 12).
fn make(interp: &mut Interp, source: Str, pattern: &[u16], flags: &[u16]) -> JsResult<ObjId> {
let re = compile_regex(interp, pattern, flags, OBJECT_BYTES + 5 * (PROP_BYTES + string_bytes(10)))?;
let (g, i, m) = (parse_flags(flags).unwrap_or_default().global, re.ignore_case, re.multiline);
let proto = interp.intrinsics.regexp_proto;
let r = interp.heap.alloc_object(Object::new(Class::RegExp(re), Some(proto)));
define_flags(interp, r, source, g, i, m)?;
Ok(r)
}
/// Defines a RegExp object's `source`, `global`, `ignoreCase`, `multiline` and `lastIndex`
/// (section 15.10.7).
fn define_flags(interp: &mut Interp, r: ObjId, source: Str, g: bool, i: bool, m: bool) -> JsResult<()> {
interp.define(r, key("source"), Prop::Data { value: Value::String(source), flags: 0 })?;
interp.define(r, key("global"), Prop::Data { value: Value::Bool(g), flags: 0 })?;
interp.define(r, key("ignoreCase"), Prop::Data { value: Value::Bool(i), flags: 0 })?;
interp.define(r, key("multiline"), Prop::Data { value: Value::Bool(m), flags: 0 })?;
interp.define(r, key("lastIndex"), Prop::Data { value: Value::Number(0.0), flags: WRITABLE })?;
Ok(())
}
/// The object a regular expression literal evaluates to (section 7.8.5): a new RegExp object
/// each time.
pub(crate) fn new_regexp(interp: &mut Interp, source: &Str, flags: &Str) -> JsResult<ObjId> {
let pattern = flat_charged(interp, source)?;
let flags = flat_charged(interp, flags)?;
make(interp, source.clone(), &pattern, &flags)
}
/// The `source` text for a pattern given to the constructor: `(?:)` for the empty pattern, and
/// `/` and line terminators escaped, so that `/source/` reads back as the same pattern. Its
/// length is reserved on the heap before it is built.
pub(crate) fn source_text(interp: &mut Interp, pattern: &[u16]) -> JsResult<Str> {
if pattern.is_empty() {
return Ok(Str::from("(?:)"));
}
let mut len = 0usize;
source_units(pattern, |u| len = len.saturating_add(u.len()));
reserve_units(interp, len)?;
let mut out = Vec::with_capacity(len);
source_units(pattern, |u| out.extend_from_slice(u));
Ok(Str::from_units(out))
}
/// Passes the units of [`source_text`] for a non-empty pattern to `emit`, a piece at a time.
fn source_units(pattern: &[u16], mut emit: impl FnMut(&[u16])) {
const BACKSLASH: u16 = 0x5C;
let mut in_class = false;
let mut i = 0;
while let Some(&u) = pattern.get(i) {
match u {
0x5C => {
// A backslash before a line terminator escapes the terminator as itself (section
// 15.10.2.10); the terminator's escape written below matches the same unit.
if pattern.get(i + 1).is_some_and(|&n| crate::chars::is_line_terminator(u32::from(n))) {
i += 1;
continue;
}
emit(pattern.get(i..i + 2).unwrap_or(&[BACKSLASH]));
i += 2;
continue;
}
0x5B => in_class = true,
0x5D => in_class = false,
0x2F if !in_class => emit(&[BACKSLASH]),
_ => {}
}
match u {
0x0A => emit(&[BACKSLASH, 0x6E]),
0x0D => emit(&[BACKSLASH, 0x72]),
0x2028 => emit(&[BACKSLASH, 0x75, 0x32, 0x30, 0x32, 0x38]),
0x2029 => emit(&[BACKSLASH, 0x75, 0x32, 0x30, 0x32, 0x39]),
_ => emit(&[u]),
}
i += 1;
}
}
/// `new RegExp(pattern, flags)` (section 15.10.4.1).
pub(crate) fn new_regexp_from(interp: &mut Interp, pattern: &Value, flags: &Value) -> JsResult<ObjId> {
if let Some((o, _)) = regexp_of(interp, pattern) {
if !matches!(flags, Value::Undefined) {
return Err(interp.type_error("flags cannot be given with a RegExp object"));
}
let pv = Value::Object(o);
let source = interp.get_value(&pv, &key("source"))?;
let source = interp.to_str(&source)?;
let mut f = String::new();
for (name, c) in [("global", 'g'), ("ignoreCase", 'i'), ("multiline", 'm')] {
if interp.get_value(&pv, &key(name))?.truthy() {
f.push(c);
}
}
let units = flat_charged(interp, &source)?;
let pattern: &[u16] = if is_empty_group(&units) { &[] } else { &units };
return make(interp, source.clone(), pattern, &f.encode_utf16().collect::<Vec<u16>>());
}
let p = if matches!(pattern, Value::Undefined) { Str::from("") } else { interp.to_str(pattern)? };
let f = if matches!(flags, Value::Undefined) { Str::from("") } else { interp.to_str(flags)? };
let units = flat_charged(interp, &p)?;
let flags = flat_charged(interp, &f)?;
let source = source_text(interp, &units)?;
make(interp, source, &units, &flags)
}
fn is_empty_group(units: &[u16]) -> bool {
units.iter().copied().eq("(?:)".encode_utf16())
}
/// `RegExp(pattern, flags)` and `new RegExp(pattern, flags)` (sections 15.10.3 and 15.10.4).
fn regexp_ctor(interp: &mut Interp, _: Value, args: &[Value], construct: bool) -> JsResult<Value> {
let (pattern, flags) = (arg(args, 0), arg(args, 1));
if !construct && regexp_of(interp, &pattern).is_some() && matches!(flags, Value::Undefined) {
return Ok(pattern);
}
new_regexp_from(interp, &pattern, &flags).map(Value::Object)
}
fn this_regexp(interp: &mut Interp, this: &Value, name: &str) -> JsResult<ObjId> {
match regexp_of(interp, this) {
Some((o, _)) => Ok(o),
None => Err(interp.type_error(&format!("RegExp.prototype.{name} was called on a value that is not a RegExp object"))),
}
}
/// The matching steps of `exec` (section 15.10.6.2, steps 3 to 11): the positions of the match
/// and of its groups ([`crate::regexp_vm::NONE`] for a group that did not take part), or `None`.
/// Reads `lastIndex` and `global`, and sets `lastIndex` as `exec` does.
pub(crate) fn exec_raw(interp: &mut Interp, rx: ObjId, s: &Str) -> JsResult<Option<Vec<usize>>> {
let Some((_, re)) = regexp_of(interp, &Value::Object(rx)) else { return Err(interp.type_error("not a RegExp object")) };
let rv = Value::Object(rx);
let last_index = key("lastIndex");
let li = interp.get_value(&rv, &last_index)?;
let li = to_integer(interp.to_number(&li)?);
let global = interp.get_value(&rv, &key("global"))?.truthy();
let units = flat_charged(interp, s)?;
let i = if global { li } else { 0.0 };
let found = if i < 0.0 || i > units.len() as f64 {
None
} else {
let mut m = Matcher::new(&re, interp)?;
if m.search(interp, &units, i as usize)? { Some(m.caps) } else { None }
};
match found {
None => {
interp.put(rx, last_index, Value::Number(0.0), true)?;
Ok(None)
}
Some(caps) => {
if global {
let end = caps.get(1).copied().unwrap_or(0);
interp.put(rx, last_index, Value::Number(end as f64), true)?;
}
Ok(Some(caps))
}
}
}
/// `exec`'s result for `rx` on `s`: the match array, or `null`.
pub(crate) fn exec_value(interp: &mut Interp, rx: ObjId, s: &Str) -> JsResult<Value> {
rooted(interp, |interp| {
let Some(caps) = exec_raw(interp, rx, s)? else { return Ok(Value::Null) };
let units = flat_charged(interp, s)?;
let a = new_array(interp);
interp.reserve_heap(2 * (PROP_BYTES + string_bytes(5)))?;
let index = caps.first().copied().unwrap_or(0);
interp.define(a, key("index"), Prop::Data { value: Value::Number(index as f64), flags: OPEN })?;
interp.define(a, key("input"), Prop::Data { value: Value::String(s.clone()), flags: OPEN })?;
for (n, pair) in caps.chunks(2).enumerate() {
let v = match pair {
[x, y] if *x != crate::regexp_vm::NONE && *y != crate::regexp_vm::NONE => substring_value(interp, &units, *x, *y)?,
_ => Value::Undefined,
};
set_element(interp, a, n, v)?;
}
Ok(Value::Object(a))
})
}
/// `RegExp.prototype.exec` (section 15.10.6.2).
fn exec(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
let rx = this_regexp(interp, &this, "exec")?;
let s = interp.to_str(&arg(args, 0))?;
exec_value(interp, rx, &s)
}
/// `RegExp.prototype.test` (section 15.10.6.3).
fn test(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
let rx = this_regexp(interp, &this, "test")?;
let s = interp.to_str(&arg(args, 0))?;
Ok(Value::Bool(exec_raw(interp, rx, &s)?.is_some()))
}
/// `RegExp.prototype.toString` (section 15.10.6.4).
fn to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
let rx = this_regexp(interp, &this, "toString")?;
let rv = Value::Object(rx);
let source = interp.get_value(&rv, &key("source"))?;
let source = interp.to_str(&source)?;
let mut flags = String::from("/");
for (name, c) in [("global", 'g'), ("ignoreCase", 'i'), ("multiline", 'm')] {
if interp.get_value(&rv, &key(name))?.truthy() {
flags.push(c);
}
}
let head = interp.concat(&Str::from("/"), &source)?;
interp.concat(&head, &Str::from(flags.as_str())).map(Value::String)
}
File diff suppressed because it is too large Load Diff
+788
View File
@@ -0,0 +1,788 @@
//! `String` (ECMA-262 5.1 section 15.5): the constructor, `String.fromCharCode` and the methods of
//! `String.prototype`, with `substr` from Annex B.
//!
//! The methods read a string's code units once (flattening a rope, whose flat copy counts as a new
//! string), take a unit of fuel for every [`UNITS_PER_FUEL`] code units they read or write, and
//! reserve each new string's bytes on the heap before building it, so a method that would make a
//! string past the heap limit ends the run instead of allocating it.
use std::rc::Rc;
use crate::LimitKind;
use crate::builtins::{arg, constructor, method, native};
use crate::heap::{Class, NativeKind, OPEN, ObjId, Object, PROP_BYTES, Prop, string_bytes};
use crate::interp::{Abrupt, HeldBytes, Interp, JsResult};
use crate::regexp_builtins::{exec_raw, new_regexp_from, regexp_of};
use crate::regexp_vm::{Matcher, NONE, use_fuel_n};
use crate::value::{Str, Value, index_key, is_str_space, to_uint32};
/// Code units a unit of fuel pays for when a built-in reads or writes text.
pub(crate) const UNITS_PER_FUEL: usize = 16;
pub(crate) fn install(interp: &mut Interp) -> JsResult<()> {
let proto = interp.intrinsics.string_proto;
let ctor = native(interp, "String", string_ctor, 1, NativeKind::Plain, true)?;
constructor(interp, "String", ctor, proto)?;
method(interp, ctor, "fromCharCode", from_char_code, 1)?;
let methods: [(&'static str, crate::heap::NativeFn, u32); 20] = [
("toString", to_string, 0),
("valueOf", to_string, 0),
("charAt", char_at, 1),
("charCodeAt", char_code_at, 1),
("concat", concat, 1),
("indexOf", index_of, 1),
("lastIndexOf", last_index_of, 1),
("localeCompare", locale_compare, 1),
("match", match_, 1),
("replace", replace, 2),
("search", search, 1),
("slice", slice, 2),
("split", split, 2),
("substring", substring, 2),
("substr", substr, 2),
("toLowerCase", to_lower_case, 0),
("toLocaleLowerCase", to_lower_case, 0),
("toUpperCase", to_upper_case, 0),
("toLocaleUpperCase", to_upper_case, 0),
("trim", trim, 0),
];
for (name, f, len) in methods {
method(interp, proto, name, f, len)?;
}
Ok(())
}
// ---- helpers the other text built-ins share ----
/// ToInteger (section 9.4) applied to a number.
pub(crate) fn to_integer(n: f64) -> f64 {
if n.is_nan() {
0.0
} else if n.is_infinite() {
n
} else {
n.trunc() + 0.0
}
}
/// Takes the fuel for reading or writing `n` code units.
pub(crate) fn charge_units(interp: &mut Interp, n: usize) -> JsResult<()> {
use_fuel_n(interp, (n / UNITS_PER_FUEL) as u64 + 1)
}
/// Reserves the heap bytes of a new string of `len` code units before it is built, ending the
/// run with the heap limit when it would not fit. Everything the caller still needs must be
/// reachable from the roots.
pub(crate) fn reserve_units(interp: &mut Interp, len: usize) -> JsResult<()> {
reserve_string(interp, len)?;
charge_units(interp, len)
}
/// [`reserve_units`] without the fuel, for a caller that took it as it read the units.
fn reserve_string(interp: &mut Interp, len: usize) -> JsResult<()> {
check_string_room(interp, len)?;
let bytes = string_bytes(len);
interp.heap.bytes = interp.heap.bytes.saturating_add(bytes);
Ok(())
}
/// Ends the run with the heap limit when a new string of `len` code units would not fit, after
/// a collection if one is needed. Everything the caller still needs must be reachable from the
/// roots.
fn check_string_room(interp: &mut Interp, len: usize) -> JsResult<()> {
if len > interp.limits.heap_bytes / 4 {
return Err(Abrupt::Limit(LimitKind::Heap));
}
interp.reserve_heap(string_bytes(len))
}
/// Pushes `item` onto `list`, first holding the bytes of the list's new capacity when it must
/// grow.
fn push_held<T>(interp: &mut Interp, held: &mut HeldBytes, list: &mut Vec<T>, item: T) -> JsResult<()> {
if list.len() == list.capacity() {
let more = list.capacity().max(4);
held.add(interp, more.saturating_mul(size_of::<T>()))?;
list.reserve_exact(more);
}
list.push(item);
Ok(())
}
/// The code units of `s`. Flattening a rope makes a copy of its units, so for a rope that copy's
/// bytes are reserved and added to the heap, and its units charged as fuel, first.
pub(crate) fn flat_charged(interp: &mut Interp, s: &Str) -> JsResult<Rc<[u16]>> {
if s.is_rope() {
reserve_units(interp, s.len())?;
}
Ok(s.flat())
}
/// A new string holding `units[start..end]`.
pub(crate) fn substring_value(interp: &mut Interp, units: &[u16], start: usize, end: usize) -> JsResult<Value> {
let part = units.get(start..end.max(start)).unwrap_or_default();
reserve_units(interp, part.len())?;
Ok(Value::String(Str::from_units(part.to_vec())))
}
/// Runs `f`, then drops the temporary roots it added.
pub(crate) fn rooted<T>(interp: &mut Interp, f: impl FnOnce(&mut Interp) -> JsResult<T>) -> JsResult<T> {
let mark = interp.temp_roots.len();
let r = f(interp);
interp.temp_roots.truncate(mark);
r
}
/// A new, empty array, kept in the temporary roots: call it inside [`rooted`].
pub(crate) fn new_array(interp: &mut Interp) -> ObjId {
let proto = interp.intrinsics.array_proto;
let a = interp.heap.alloc_object(Object::new(Class::Array { length: 0, length_writable: true }, Some(proto)));
interp.temp_roots.push(Value::Object(a));
a
}
/// Sets element `i` of an array made by [`new_array`], reserving its bytes first.
pub(crate) fn set_element(interp: &mut Interp, a: ObjId, i: usize, v: Value) -> JsResult<()> {
let i = u32::try_from(i).map_err(|_| Abrupt::Limit(LimitKind::Heap))?;
interp.temp_roots.push(v.clone());
let reserved = interp.reserve_heap(PROP_BYTES + string_bytes(10));
interp.temp_roots.pop();
reserved?;
interp.use_fuel()?;
interp.define(a, index_key(i), Prop::Data { value: v, flags: OPEN })
}
/// CheckObjectCoercible on `this`, then ToString.
pub(crate) fn this_str(interp: &mut Interp, this: &Value, method: &str) -> JsResult<Str> {
if this.is_nullish() {
let message = format!("String.prototype.{method} was called on null or undefined");
return Err(interp.type_error(&message));
}
interp.to_str(this)
}
/// ToInteger of an argument.
fn int_arg(interp: &mut Interp, args: &[Value], i: usize) -> JsResult<f64> {
let n = interp.to_number(&arg(args, i))?;
Ok(to_integer(n))
}
/// Clamps an integer to `0..=len`.
fn clamp(n: f64, len: usize) -> usize {
if n <= 0.0 {
0
} else if n >= len as f64 {
len
} else {
n as usize
}
}
/// The first index at `from` or after where `needle` occurs in `hay`. Every position tried takes
/// fuel.
fn find(interp: &mut Interp, hay: &[u16], needle: &[u16], from: usize) -> JsResult<Option<usize>> {
if needle.len() > hay.len() {
return Ok(None);
}
let last = hay.len() - needle.len();
let per = (needle.len() / UNITS_PER_FUEL) as u64 + 1;
let mut steps = 0u64;
let mut i = from;
let mut found = None;
while i <= last {
steps += per;
if steps >= 1024 {
use_fuel_n(interp, std::mem::take(&mut steps))?;
}
if hay.get(i..i + needle.len()) == Some(needle) {
found = Some(i);
break;
}
i += 1;
}
use_fuel_n(interp, steps)?;
Ok(found)
}
// ---- the constructor ----
/// `String(value)` and `new String(value)` (sections 15.5.1 and 15.5.2).
fn string_ctor(interp: &mut Interp, _: Value, args: &[Value], construct: bool) -> JsResult<Value> {
let s = match args.first() {
Some(v) => interp.to_str(v)?,
None => Str::from(""),
};
if !construct {
return Ok(Value::String(s));
}
let proto = interp.intrinsics.string_proto;
Ok(Value::Object(interp.heap.alloc_object(Object::new(Class::String(s), Some(proto)))))
}
/// `String.fromCharCode(…)` (section 15.5.3.2).
fn from_char_code(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
let mut units = Vec::with_capacity(args.len());
for a in args {
let n = interp.to_number(a)?;
units.push(to_uint32(n) as u16);
}
reserve_units(interp, units.len())?;
Ok(Value::String(Str::from_units(units)))
}
// ---- String.prototype ----
/// `toString` and `valueOf` (sections 15.5.4.2 and 15.5.4.3).
fn to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
match &this {
Value::String(_) => Ok(this),
Value::Object(o) => match interp.heap.object(*o).map(|o| &o.class) {
Some(Class::String(s)) => Ok(Value::String(s.clone())),
_ => Err(interp.type_error("String.prototype.toString was called on a value that is not a string")),
},
_ => Err(interp.type_error("String.prototype.toString was called on a value that is not a string")),
}
}
fn char_at(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
let s = this_str(interp, &this, "charAt")?;
let pos = int_arg(interp, args, 0)?;
if pos < 0.0 || pos >= s.len() as f64 {
return Ok(Value::str(""));
}
let unit = flat_charged(interp, &s)?.get(pos as usize).copied().unwrap_or(0);
Ok(Value::String(Str::from_units(vec![unit])))
}
fn char_code_at(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
let s = this_str(interp, &this, "charCodeAt")?;
let pos = int_arg(interp, args, 0)?;
if pos < 0.0 || pos >= s.len() as f64 {
return Ok(Value::Number(f64::NAN));
}
Ok(Value::Number(f64::from(flat_charged(interp, &s)?.get(pos as usize).copied().unwrap_or(0))))
}
fn concat(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
let mut s = this_str(interp, &this, "concat")?;
for a in args {
let r = interp.to_str(a)?;
s = interp.concat(&s, &r)?;
}
Ok(Value::String(s))
}
fn index_of(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
let s = this_str(interp, &this, "indexOf")?;
let search = interp.to_str(&arg(args, 0))?;
let pos = int_arg(interp, args, 1)?;
let (hay, needle) = (flat_charged(interp, &s)?, flat_charged(interp, &search)?);
let start = clamp(pos, hay.len());
let found = find(interp, &hay, &needle, start)?;
Ok(Value::Number(found.map_or(-1.0, |i| i as f64)))
}
fn last_index_of(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
let s = this_str(interp, &this, "lastIndexOf")?;
let search = interp.to_str(&arg(args, 0))?;
let n = interp.to_number(&arg(args, 1))?;
let pos = if n.is_nan() { f64::INFINITY } else { to_integer(n) };
let (hay, needle) = (flat_charged(interp, &s)?, flat_charged(interp, &search)?);
let start = clamp(pos, hay.len());
if needle.len() > hay.len() {
return Ok(Value::Number(-1.0));
}
let mut k = start.min(hay.len() - needle.len());
let per = (needle.len() / UNITS_PER_FUEL) as u64 + 1;
loop {
use_fuel_n(interp, per)?;
if hay.get(k..k + needle.len()) == Some(&needle[..]) {
return Ok(Value::Number(k as f64));
}
if k == 0 {
return Ok(Value::Number(-1.0));
}
k -= 1;
}
}
/// `localeCompare` (section 15.5.4.9): code unit order.
fn locale_compare(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
let s = this_str(interp, &this, "localeCompare")?;
let that = interp.to_str(&arg(args, 0))?;
let (a, b) = (flat_charged(interp, &s)?, flat_charged(interp, &that)?);
charge_units(interp, a.len().min(b.len()))?;
Ok(Value::Number(match a.cmp(&b) {
std::cmp::Ordering::Less => -1.0,
std::cmp::Ordering::Equal => 0.0,
std::cmp::Ordering::Greater => 1.0,
}))
}
fn slice(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
let s = this_str(interp, &this, "slice")?;
let len = s.len() as f64;
let start = int_arg(interp, args, 0)?;
let end = if matches!(arg(args, 1), Value::Undefined) { len } else { int_arg(interp, args, 1)? };
let from = if start < 0.0 { (len + start).max(0.0) } else { start.min(len) };
let to = if end < 0.0 { (len + end).max(0.0) } else { end.min(len) };
if to <= from {
return Ok(Value::str(""));
}
let units = flat_charged(interp, &s)?;
substring_value(interp, &units, from as usize, to as usize)
}
fn substring(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
let s = this_str(interp, &this, "substring")?;
let len = s.len();
let start = int_arg(interp, args, 0)?;
let end = if matches!(arg(args, 1), Value::Undefined) { len as f64 } else { int_arg(interp, args, 1)? };
let (a, b) = (clamp(start, len), clamp(end, len));
let units = flat_charged(interp, &s)?;
substring_value(interp, &units, a.min(b), a.max(b))
}
/// `substr` (Annex B.2.3). Its first step applies ToString to `this` with no object-coercible
/// check, so `null` and `undefined` give substrings of `"null"` and `"undefined"`.
fn substr(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
let s = interp.to_str(&this)?;
let len = s.len() as f64;
let start = int_arg(interp, args, 0)?;
let length = if matches!(arg(args, 1), Value::Undefined) { f64::INFINITY } else { int_arg(interp, args, 1)? };
let start = if start >= 0.0 { start } else { (len + start).max(0.0) };
let count = length.max(0.0).min(len - start);
if count <= 0.0 {
return Ok(Value::str(""));
}
let units = flat_charged(interp, &s)?;
substring_value(interp, &units, start as usize, (start + count) as usize)
}
/// The UTF-16 length of `c` mapped to lower or upper case.
fn mapped_len(c: char, upper: bool) -> usize {
if upper { c.to_uppercase().map(char::len_utf16).sum() } else { c.to_lowercase().map(char::len_utf16).sum() }
}
/// Maps the characters of `units` to lower or upper case (Unicode's full mappings, final sigma
/// included), passing lone surrogates through. The result's length is reserved before it is built.
fn map_case(interp: &mut Interp, units: &[u16], upper: bool) -> JsResult<Vec<u16>> {
let len: usize = char::decode_utf16(units.iter().copied()).map(|r| r.map_or(1, |c| mapped_len(c, upper))).sum();
reserve_units(interp, len)?;
let mut out = Vec::with_capacity(len);
let mut run = String::new();
let flush = |run: &mut String, out: &mut Vec<u16>| {
let mapped = if upper { run.to_uppercase() } else { run.to_lowercase() };
out.extend(mapped.encode_utf16());
run.clear();
};
for r in char::decode_utf16(units.iter().copied()) {
match r {
Ok(c) => run.push(c),
Err(e) => {
flush(&mut run, &mut out);
out.push(e.unpaired_surrogate());
}
}
}
flush(&mut run, &mut out);
Ok(out)
}
fn to_lower_case(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
let s = this_str(interp, &this, "toLowerCase")?;
let units = flat_charged(interp, &s)?;
let out = map_case(interp, &units, false)?;
Ok(Value::String(Str::from_units(out)))
}
fn to_upper_case(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
let s = this_str(interp, &this, "toUpperCase")?;
let units = flat_charged(interp, &s)?;
let out = map_case(interp, &units, true)?;
Ok(Value::String(Str::from_units(out)))
}
fn trim(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
let s = this_str(interp, &this, "trim")?;
let units = flat_charged(interp, &s)?;
// The scan reads every unit once at most.
charge_units(interp, units.len())?;
let start = units.iter().position(|&u| !is_str_space(u)).unwrap_or(units.len());
let end = units.iter().rposition(|&u| !is_str_space(u)).map_or(start, |i| i + 1);
if start == 0 && end == units.len() {
return Ok(Value::String(s));
}
substring_value(interp, &units, start, end)
}
// ---- the methods that take a regular expression ----
/// The regular expression argument of `match` and `search`: a RegExp object, or a new one made
/// from the value as `new RegExp(value)` would.
fn regexp_arg(interp: &mut Interp, v: &Value) -> JsResult<ObjId> {
if let Some((o, _)) = regexp_of(interp, v) {
return Ok(o);
}
let o = new_regexp_from(interp, v, &Value::Undefined)?;
interp.temp_roots.push(Value::Object(o));
Ok(o)
}
/// `match` (section 15.5.4.10).
fn match_(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
rooted(interp, |interp| {
let s = this_str(interp, &this, "match")?;
let rx = regexp_arg(interp, &arg(args, 0))?;
let global = interp.get_value(&Value::Object(rx), &crate::heap::key("global"))?.truthy();
if !global {
return crate::regexp_builtins::exec_value(interp, rx, &s);
}
let last_index = crate::heap::key("lastIndex");
interp.put(rx, last_index.clone(), Value::Number(0.0), true)?;
let a = new_array(interp);
let mut n = 0usize;
let mut previous = 0.0;
let units = flat_charged(interp, &s)?;
while let Some(caps) = exec_raw(interp, rx, &s)? {
let this_index = interp.get_value(&Value::Object(rx), &last_index)?;
let this_index = interp.to_number(&this_index)?;
if this_index == previous {
previous = this_index + 1.0;
interp.put(rx, last_index.clone(), Value::Number(previous), true)?;
} else {
previous = this_index;
}
let m = substring_value(interp, &units, caps.first().copied().unwrap_or(0), caps.get(1).copied().unwrap_or(0))?;
set_element(interp, a, n, m)?;
n += 1;
}
Ok(if n == 0 { Value::Null } else { Value::Object(a) })
})
}
/// `search` (section 15.5.4.12): the index of the first match from the start, ignoring
/// `lastIndex` and `global`.
fn search(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
rooted(interp, |interp| {
let s = this_str(interp, &this, "search")?;
let rx = regexp_arg(interp, &arg(args, 0))?;
let Some((_, re)) = regexp_of(interp, &Value::Object(rx)) else { return Err(interp.type_error("not a regular expression")) };
let units = flat_charged(interp, &s)?;
let mut m = Matcher::new(&re, interp)?;
let found = m.search(interp, &units, 0)?;
Ok(Value::Number(if found { m.caps.first().copied().unwrap_or(0) as f64 } else { -1.0 }))
})
}
/// A piece of a result string: a range of the input, a range of the replacement pattern, or the
/// units of a string a replacement function returned.
enum Piece {
Range(usize, usize),
Template(usize, usize),
Units(Rc<[u16]>),
}
impl Piece {
fn len(&self) -> usize {
match self {
Piece::Range(a, b) | Piece::Template(a, b) => b.saturating_sub(*a),
Piece::Units(u) => u.len(),
}
}
}
/// The pieces of a result string and the length they add up to.
#[derive(Default)]
struct Pieces {
list: Vec<Piece>,
len: usize,
}
impl Pieces {
/// Adds `piece`, holding its bytes and taking fuel for its units, and ends the run with the
/// heap limit once the result would not fit.
fn add(&mut self, interp: &mut Interp, held: &mut HeldBytes, piece: Piece) -> JsResult<()> {
let n = piece.len();
if n == 0 {
return Ok(());
}
push_held(interp, held, &mut self.list, piece)?;
self.len = self.len.saturating_add(n);
charge_units(interp, n)?;
check_string_room(interp, self.len)
}
/// Joins the pieces into a new string, reserving its length first. `units` is the input and
/// `template` the replacement pattern.
fn build(&self, interp: &mut Interp, units: &[u16], template: &[u16]) -> JsResult<Value> {
reserve_string(interp, self.len)?;
let mut out = Vec::with_capacity(self.len);
for p in &self.list {
match p {
Piece::Range(a, b) => out.extend_from_slice(units.get(*a..*b).unwrap_or_default()),
Piece::Template(a, b) => out.extend_from_slice(template.get(*a..*b).unwrap_or_default()),
Piece::Units(u) => out.extend_from_slice(u),
}
}
Ok(Value::String(Str::from_units(out)))
}
}
/// A part of a replacement pattern (section 15.5.4.11, Table 22).
#[derive(Clone, Copy)]
enum Part {
/// The pattern's own text in a range.
Text(usize, usize),
/// `$&`
Matched,
/// `` $` ``
Before,
/// `$'`
After,
/// `$n` or `$nn`
Group(usize),
}
impl Part {
/// The piece this part gives for one match: `caps` holds the match and its groups as pairs of
/// positions in an input of `len` units.
fn piece(self, caps: &[usize], len: usize) -> Option<Piece> {
let (start, end) = (caps.first().copied().unwrap_or(0), caps.get(1).copied().unwrap_or(0));
match self {
Part::Text(a, b) => Some(Piece::Template(a, b)),
Part::Matched => Some(Piece::Range(start, end)),
Part::Before => Some(Piece::Range(0, start)),
Part::After => Some(Piece::Range(end, len)),
Part::Group(n) => {
let (s, e) = (caps.get(2 * n).copied().unwrap_or(NONE), caps.get(2 * n + 1).copied().unwrap_or(NONE));
(s != NONE && e != NONE).then_some(Piece::Range(s, e))
}
}
}
}
/// Reads a replacement pattern once into its parts, for a regular expression with `groups`
/// groups. The parts are held against the heap limit, and the units read take fuel.
fn parse_template(interp: &mut Interp, held: &mut HeldBytes, template: &[u16], groups: usize) -> JsResult<Vec<Part>> {
charge_units(interp, template.len())?;
let digit = |i: usize| template.get(i).filter(|&&u| (0x30..=0x39).contains(&u)).map(|&u| usize::from(u - 0x30));
let mut parts = Vec::new();
let mut lit = 0;
let mut i = 0;
while let Some(&u) = template.get(i) {
if u != 0x24 {
i += 1;
continue;
}
let (part, taken) = match template.get(i + 1) {
// `$$` gives one `$`: the text up to and including the first.
Some(0x24) => {
push_held(interp, held, &mut parts, Part::Text(lit, i + 1))?;
lit = i + 2;
i += 2;
continue;
}
Some(0x26) => (Part::Matched, 2),
Some(0x60) => (Part::Before, 2),
Some(0x27) => (Part::After, 2),
_ => match (digit(i + 1), digit(i + 2)) {
(Some(a), Some(b)) if (1..=groups).contains(&(a * 10 + b)) => (Part::Group(a * 10 + b), 3),
(Some(a), _) if (1..=groups).contains(&a) => (Part::Group(a), 2),
_ => {
i += 1;
continue;
}
},
};
if i > lit {
push_held(interp, held, &mut parts, Part::Text(lit, i))?;
}
push_held(interp, held, &mut parts, part)?;
i += taken;
lit = i;
}
if template.len() > lit {
push_held(interp, held, &mut parts, Part::Text(lit, template.len()))?;
}
Ok(parts)
}
/// `replace` (section 15.5.4.11). The matches, the parsed replacement pattern and the pieces of
/// the result are held against the heap limit while the result is put together, and the
/// result's length is checked against the heap as each piece is added.
fn replace(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
rooted(interp, |interp| {
let s = this_str(interp, &this, "replace")?;
let search_value = arg(args, 0);
let replace_value = arg(args, 1);
let units = flat_charged(interp, &s)?;
let mut held = HeldBytes::new(interp);
// Each match as the positions of the match and its groups.
let mut matches: Vec<Vec<usize>> = Vec::new();
if let Some((rx, _)) = regexp_of(interp, &search_value) {
let global = interp.get_value(&search_value, &crate::heap::key("global"))?.truthy();
if global {
let last_index = crate::heap::key("lastIndex");
interp.put(rx, last_index.clone(), Value::Number(0.0), true)?;
let mut previous = 0.0;
while let Some(caps) = exec_raw(interp, rx, &s)? {
held.add(interp, size_of::<usize>().saturating_mul(caps.capacity()))?;
push_held(interp, &mut held, &mut matches, caps)?;
let this_index = interp.get_value(&search_value, &last_index)?;
let this_index = interp.to_number(&this_index)?;
if this_index == previous {
previous = this_index + 1.0;
interp.put(rx, last_index.clone(), Value::Number(previous), true)?;
} else {
previous = this_index;
}
}
} else if let Some(caps) = exec_raw(interp, rx, &s)? {
matches.push(caps);
}
} else {
let search = interp.to_str(&search_value)?;
let needle = flat_charged(interp, &search)?;
if let Some(i) = find(interp, &units, &needle, 0)? {
matches.push(vec![i, i + needle.len()]);
}
}
let replacer = interp.is_callable(&replace_value).then(|| replace_value.clone());
let template = match &replacer {
Some(_) => None,
None => {
let t = interp.to_str(&replace_value)?;
Some(flat_charged(interp, &t)?)
}
};
if matches.is_empty() {
return Ok(Value::String(s));
}
let template: &[u16] = template.as_deref().unwrap_or_default();
// Every match has the same number of groups.
let groups = matches.first().map_or(0, |caps| (caps.len() / 2).saturating_sub(1));
let parts = if replacer.is_none() { parse_template(interp, &mut held, template, groups)? } else { Vec::new() };
let mut pieces = Pieces::default();
let mut last = 0usize;
for caps in &matches {
let (start, end) = (caps.first().copied().unwrap_or(0), caps.get(1).copied().unwrap_or(0));
pieces.add(interp, &mut held, Piece::Range(last, start))?;
match &replacer {
Some(f) => {
let mut call_args = Vec::with_capacity(caps.len() / 2 + 2);
for pair in caps.chunks(2) {
let v = match pair {
[a, b] if *a != NONE && *b != NONE => substring_value(interp, &units, *a, *b)?,
_ => Value::Undefined,
};
call_args.push(v);
}
call_args.push(Value::Number(start as f64));
call_args.push(Value::String(s.clone()));
let r = interp.call(f, &Value::Undefined, &call_args)?;
let text = interp.to_str(&r)?;
let text = flat_charged(interp, &text)?;
pieces.add(interp, &mut held, Piece::Units(text))?;
}
None => {
// Reading the parts takes fuel even when they give empty pieces.
use_fuel_n(interp, (parts.len() / UNITS_PER_FUEL) as u64)?;
for part in &parts {
if let Some(p) = part.piece(caps, units.len()) {
pieces.add(interp, &mut held, p)?;
}
}
}
}
use_fuel_n(interp, 1)?;
last = end;
}
pieces.add(interp, &mut held, Piece::Range(last, units.len()))?;
pieces.build(interp, &units, template)
})
}
/// `split` (section 15.5.4.14).
fn split(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
rooted(interp, |interp| {
let s = this_str(interp, &this, "split")?;
let separator = arg(args, 0);
let limit = arg(args, 1);
let a = new_array(interp);
let lim = if matches!(limit, Value::Undefined) { u32::MAX } else { to_uint32(interp.to_number(&limit)?) };
let rx = regexp_of(interp, &separator);
let sep = match &rx {
Some(_) => None,
None => {
let sep = interp.to_str(&separator)?;
Some(flat_charged(interp, &sep)?)
}
};
if lim == 0 {
return Ok(Value::Object(a));
}
if matches!(separator, Value::Undefined) {
set_element(interp, a, 0, Value::String(s))?;
return Ok(Value::Object(a));
}
let units = flat_charged(interp, &s)?;
let size = units.len();
let re: Option<Rc<crate::regexp_vm::Regex>> = rx.map(|(_, re)| re);
let mut matcher = re.as_deref().map(|re| Matcher::new(re, interp)).transpose()?;
// SplitMatch (section 15.5.4.14): the end of a separator match at `q` and the groups.
let mut split_match = |interp: &mut Interp, q: usize| -> JsResult<Option<(usize, Vec<usize>)>> {
match (&mut matcher, &sep) {
(Some(m), _) => {
if m.match_at(interp, &units, q)? {
Ok(Some((m.caps.get(1).copied().unwrap_or(q), m.caps.get(2..).map(<[usize]>::to_vec).unwrap_or_default())))
} else {
Ok(None)
}
}
(None, Some(r)) => {
use_fuel_n(interp, (r.len() / UNITS_PER_FUEL) as u64 + 1)?;
if units.get(q..q + r.len()) == Some(&r[..]) { Ok(Some((q + r.len(), Vec::new()))) } else { Ok(None) }
}
(None, None) => Ok(None),
}
};
if size == 0 {
if split_match(interp, 0)?.is_none() {
set_element(interp, a, 0, Value::String(s.clone()))?;
}
return Ok(Value::Object(a));
}
let mut n = 0usize;
let (mut p, mut q) = (0usize, 0usize);
while q != size {
match split_match(interp, q)? {
None => q += 1,
Some((e, _)) if e == p => q += 1,
Some((e, caps)) => {
let t = substring_value(interp, &units, p, q)?;
set_element(interp, a, n, t)?;
n += 1;
if n as u64 == u64::from(lim) {
return Ok(Value::Object(a));
}
p = e;
for pair in caps.chunks(2) {
let v = match pair {
[x, y] if *x != NONE && *y != NONE => substring_value(interp, &units, *x, *y)?,
_ => Value::Undefined,
};
set_element(interp, a, n, v)?;
n += 1;
if n as u64 == u64::from(lim) {
return Ok(Value::Object(a));
}
}
q = p;
}
}
}
let t = substring_value(interp, &units, p, size)?;
set_element(interp, a, n, t)?;
Ok(Value::Object(a))
})
}
+633
View File
@@ -0,0 +1,633 @@
//! Tests of `String`, `Number`, `RegExp` and `Date` run through a realm.
use proptest::prelude::*;
use crate::regexp_vm::{INST_BYTES, ITEM_BYTES};
use crate::{JsValue, LimitKind, Limits, Options, Realm, RunError, RunErrorKind};
fn run_in(realm: &mut Realm, src: &str) -> JsValue {
match realm.run(src) {
Ok(v) => v,
Err(e) => panic!("{src}\n{e:?}"),
}
}
fn run(src: &str) -> JsValue {
run_in(&mut Realm::new(), src)
}
fn string(src: &str) -> String {
match run(src) {
JsValue::String(s) => s,
other => panic!("{src}: {other:?}"),
}
}
/// Script helpers that need no `Array.prototype` method: `show(a, sep)` joins an array (`null`
/// shows as `null`, `undefined` elements as nothing), `nan(n)` tells NaN apart.
const HELPERS: &str = "function show(a, sep) { if (a === null) return 'null'; var s = ''; \
for (var i = 0; i < a.length; i++) s += (i ? (sep === undefined ? ',' : sep) : '') + (a[i] === undefined ? '' : a[i]); return s; } \
function nan(n) { return n !== n ? 'NaN' : 'num'; }\n";
/// Runs `src` after [`HELPERS`] and checks it gives the string `want`.
fn shows(src: &str, want: &str) {
match run(&format!("{HELPERS}{src}")) {
JsValue::String(s) => assert_eq!(s, want, "{src}"),
other => panic!("{src}: {other:?}"),
}
}
fn truth(src: &str) {
assert_eq!(run(src), JsValue::Bool(true), "{src}");
}
fn fail_with(mut realm: Realm, src: &str) -> RunError {
match realm.run(src) {
Ok(v) => panic!("{src} completed with {v:?}"),
Err(e) => e,
}
}
fn thrown(src: &str) -> String {
let e = fail_with(Realm::new(), src);
assert_eq!(e.kind, RunErrorKind::Thrown, "{src}: {e:?}");
e.message
}
// ---- String ----
#[test]
fn string_converts_and_wraps() {
assert_eq!(string("String(12.5) + String() + String(null)"), "12.5null");
truth("var s = new String('ab'); typeof s === 'object' && s.length === 2 && s[1] === 'b' && s.valueOf() === 'ab'");
assert_eq!(string("String.fromCharCode(104, 105, 65536 + 33)"), "hi!");
assert!(thrown("String.prototype.toString.call({})").starts_with("TypeError"));
assert!(thrown("String.prototype.trim.call(null)").starts_with("TypeError"));
}
#[test]
fn string_methods_follow_section_15_5_4() {
let cases: &[(&str, &str)] = &[
("'abc'.charAt(1) + 'abc'.charAt(5) + 'abc'.charAt(-1)", "b"),
("'' + 'abc'.charCodeAt(0) + nan('abc'.charCodeAt(3))", "97NaN"),
("'a'.concat('b', 1, null)", "ab1null"),
("'' + 'abcabc'.indexOf('c') + 'abcabc'.indexOf('c', 3) + 'abc'.indexOf('') + 'abc'.indexOf('', 9)", "2503"),
("'' + 'abcabc'.lastIndexOf('c') + 'abcabc'.lastIndexOf('c', 4) + 'abc'.lastIndexOf('x')", "52-1"),
("'' + 'a'.localeCompare('b') + 'b'.localeCompare('a') + 'a'.localeCompare('a')", "-110"),
("'abcdef'.slice(1, -1) + '|' + 'abcdef'.slice(-2) + '|' + 'abc'.slice(2, 1)", "bcde|ef|"),
("'abcdef'.substring(4, 1) + '|' + 'abcdef'.substring(-3, 2) + '|' + 'abc'.substring(1)", "bcd|ab|bc"),
("'abcdef'.substr(-3, 2) + '|' + 'abcdef'.substr(1) + '|' + 'abc'.substr(1, -1)", "de|bcdef|"),
("'\\u00dfa\\u03a3'.toUpperCase() + '|' + 'A\\u03a3'.toLowerCase() + '|' + 'A\\u03a3b'.toLowerCase()", "SSA\u{3a3}|a\u{3c2}|a\u{3c3}b"),
("'[' + ' \\t\\n\\u00a0\\ufeffx y\\u2028 '.trim() + ']'", "[x y]"),
("show('a,b,,c'.split(','), '|')", "a|b||c"),
("show('abc'.split(''), '|') + ';' + 'abc'.split().length + ';' + show('abc'.split('', 2), '|')", "a|b|c;1;a|b"),
("''.split('').length + ';' + ''.split('x').length", "0;1"),
("show('a1b22c'.split(/(\\d)+/), '|')", "a|1|b|2|c"),
("show('ab'.split(/(?:)/), '|') + ';' + show('ab'.split(/a*?/), '|')", "a|b;a|b"),
("'aaa'.replace('a', '$&$&') + ';' + 'abc'.replace('b', '[$`|$\\'|$$]')", "aaaa;a[a|c|$]c"),
("'John Smith'.replace(/(\\w+)\\s(\\w+)/, '$2, $1') + ';' + 'x'.replace(/x/, '$1$01$10')", "Smith, John;$1$01$10"),
("'a-b-c'.replace(/-/g, function (m, i, s) { return '[' + i + s.length + ']'; })", "a[15]b[35]c"),
("'aaa'.replace(/a*?/g, '-')", "-a-a-a-"),
("'abc'.match(/b/).index + ';' + show('abab'.match(/a(b)/g), '|') + ';' + ('x'.match(/y/g) === null)", "1;ab|ab;true"),
("'' + 'abc'.search(/c/) + 'abc'.search('b') + 'abc'.search(/x/)", "21-1"),
];
for (src, want) in cases {
shows(src, want);
}
}
#[test]
fn string_methods_count_against_the_heap_before_allocating() {
// Doubling with `concat` and `replace` stops at the heap limit, not at an allocation failure.
let limits = Limits { heap_bytes: 8 << 20, ..Limits::default() };
for src in [
"var s = 'x'; for (;;) s = s.concat(s);",
"var s = 'xx'; for (;;) s = s.replace(/x/g, 'xx');",
"var s = 'x'; for (;;) s = (s + s).toUpperCase();",
"var s = 'x'; for (;;) s = (s + s + s).slice(0) + s.substr(0);",
] {
let e = fail_with(Realm::with_options(Options::default(), limits.clone()), src);
assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{src}: {e:?}");
}
// A replacement that would build a string past the heap limit is refused before it is built.
let limits = Limits { heap_bytes: 4 << 20, ..Limits::default() };
let e = fail_with(
Realm::with_options(Options::default(), limits),
"var big = 'yyyyyyyyyyyyyyyy'; for (var i = 0; i < 15; i++) big = big + big; 'xxxxxxxxxx'.replace(/x/g, big)",
);
assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{e:?}");
}
// ---- Number ----
#[test]
fn numbers_format_as_section_15_7_4_says() {
let cases: &[(&str, &str)] = &[
("(255).toString(16) + ' ' + (-255).toString(2) + ' ' + (0.5).toString(2) + ' ' + (1e21).toString(36)", "ff -11111111 0.1 5v1j4f4ds7c000"),
("(123.456).toString() + ' ' + (1e21).toString(10) + ' ' + (0.000001).toString()", "123.456 1e+21 0.000001"),
("(0.5).toFixed(0) + ' ' + (1.255).toFixed(2) + ' ' + (0.1).toFixed(20) + ' ' + (1e21).toFixed(2)", "1 1.25 0.10000000000000000555 1e+21"),
("(123456).toExponential(2) + ' ' + (123456).toExponential() + ' ' + (0).toExponential(2)", "1.23e+5 1.23456e+5 0.00e+0"),
(
"(123.456).toPrecision(4) + ' ' + (0.0000001234).toPrecision(2) + ' ' + (123456).toPrecision(2) + ' ' + (5).toPrecision()",
"123.5 1.2e-7 1.2e+5 5",
),
(
"(-1.5).toFixed(0) + ' ' + (NaN).toFixed(2) + ' ' + (Infinity).toExponential(50) + ' ' + (-Infinity).toPrecision(50)",
"-2 NaN Infinity -Infinity",
),
("new Number(5).toString(2) + ' ' + Number('0x10') + ' ' + Number() + ' ' + Number.MIN_VALUE", "101 16 0 5e-324"),
];
for (src, want) in cases {
assert_eq!(string(src), *want, "{src}");
}
truth("Number.MAX_VALUE === 1.7976931348623157e308 && Number.POSITIVE_INFINITY === 1/0 && Number.NaN !== Number.NaN");
truth(
"typeof new Number(1) === 'object' && new Number(7).valueOf() === 7 && Object.prototype.toString.call(Number.prototype) === '[object Number]'",
);
for src in [
"(1).toFixed(21)",
"(1).toFixed(-1)",
"(1).toExponential(21)",
"(1).toPrecision(0)",
"(1).toPrecision(22)",
"(1).toString(1)",
"(1).toString(37)",
] {
assert!(thrown(src).starts_with("RangeError"), "{src}");
}
assert!(thrown("Number.prototype.toFixed.call('1', 2)").starts_with("TypeError"));
}
// ---- RegExp ----
#[test]
fn regular_expressions_match_as_section_15_10_2_says() {
let cases: &[(&str, &str)] = &[
("show(/a(b)?c/.exec('ac'))", "ac,"),
("show(/(z)((a+)?(b+)?(c))*/.exec('zaacbbbcac'))", "zaacbbbcac,z,ac,a,,c"),
("show(/(a*)*/.exec('b'))", ","),
("show(/(a*)b\\1+/.exec('baaaac'))", "b,"),
("show(/(?=(a+))/.exec('baaabac'))", ",aaa"),
("show(/(?=(a+))a*b\\1/.exec('baaabac'))", "aba,a"),
("show(/(.*?)a(?!(a+)b\\2c)\\2(.*)/.exec('baaabaac'))", "baaabaac,ba,,abaac"),
("show(/((a)|b)+/.exec('ab'))", "ab,b,"),
("show(/a[a-z]{2,4}/.exec('abcdefghi'))", "abcde"),
("show(/a[a-z]{2,4}?/.exec('abcdefghi'))", "abc"),
("show(/(aa|aabaac|ba|b|c)*/.exec('aabaac'))", "aaba,ba"),
("show(/[^\\d\\s]+/.exec('12 ab3'))", "ab"),
("show(/\\bfoo\\B/.exec('a foox'))", "foo"),
("show(/^b/m.exec('a\\nb')) + /^b/.test('a\\nb')", "bfalse"),
("/ß/i.test('SS') + ' ' + show(/[a-z]+/i.exec('HeLLo')) + ' ' + /\\u017f/i.test('s')", "false HeLLo false"),
("/[\\u017f]/i.test('S') + ' ' + /k/i.test('\\u212a')", "false false"),
("show(/\\1(a)/.exec('aa')) + ';' + show(/(a\\1)/.exec('aa'))", "a,a;a,a"),
("/\\0\\x41\\u0042\\cJ\\101/.test('\\0AB\\nA') + ' ' + /[\\b]/.test('\\b') + ' ' + /a{,2}/.test('a{,2}')", "true true true"),
("/]/.test(']') + ' ' + show(/[\\d-z]+/.exec('5-z')) + ' ' + /\\8/.test('8')", "true 5-z true"),
("/(?:a|b)*c/.exec('ababc').index + ' ' + /x*/.exec('yyy').index", "0 0"),
("/[]/.test('a') + ' ' + /[^]/.test('\\n')", "false true"),
// An octal escape starting with 4 to 7 takes two digits, so `\477-z` is `'` and the range `7-z`.
("show(/[\\477-z]+/.exec(\"'8y\")) + ' ' + new RegExp('[\\\\477-z]').test('8') + ' ' + /[\\377]/.test('\\xff')", "'8y true true"),
];
for (src, want) in cases {
shows(src, want);
}
}
#[test]
fn a_realm_made_under_low_limits_has_every_built_in() {
let low = Limits { fuel: 0, heap_bytes: 1, ..Limits::default() };
let mut realm = Realm::with_options(Options::default(), low.clone());
assert_eq!(realm.limits(), &low);
realm.set_limits(Limits::default());
assert_eq!(
run_in(&mut realm, "typeof String + typeof Number + typeof RegExp + typeof Date + typeof /a/.exec + typeof new Date(0).getTime"),
JsValue::String("function".repeat(6))
);
}
#[test]
fn regexp_objects_follow_sections_15_10_4_to_15_10_7() {
truth("var r = /a/g; r.lastIndex === 0 && r.global && !r.ignoreCase && !r.multiline && r.source === 'a'");
truth(
"var r = /a/g; r.exec('aa'); var i = r.lastIndex; r.exec('aa'); var j = r.lastIndex; r.exec('aa'); i === 1 && j === 2 && r.lastIndex === 0",
);
truth("var r = /a/; r.lastIndex = 5; r.test('a') && r.lastIndex === 5");
truth("var r = new RegExp('a/b', 'im'); r.source === 'a\\\\/b' && r.ignoreCase && r.multiline && String(r) === '/a\\\\/b/im'");
truth("String(new RegExp()) === '/(?:)/' && new RegExp('').test('x')");
truth("var r = new RegExp('[/]'); String(r) === '/[/]/' && eval(String(r)).test('/') && eval(String(new RegExp('a/'))).source === 'a\\\\/'");
truth("var r = /x/g; RegExp(r) === r && new RegExp(r) !== r && new RegExp(r).global");
truth("var m = /(a)|(b)/.exec('b'); m.length === 3 && m[1] === undefined && m[2] === 'b' && m.index === 0 && m.input === 'b'");
truth("Object.prototype.toString.call(RegExp.prototype) === '[object RegExp]' && Object.prototype.toString.call(/x/) === '[object RegExp]'");
truth("var p = RegExp.prototype; p.hasOwnProperty('global') && p.hasOwnProperty('lastIndex') && p.source === '(?:)' && p.global === false");
for src in ["new RegExp('(')", "new RegExp('a', 'gg')", "new RegExp('a', 'x')", "new RegExp('a**')", "new RegExp('[b-a]')"] {
assert!(thrown(src).starts_with("SyntaxError"), "{src}");
}
assert!(thrown("new RegExp(/a/, 'g')").starts_with("TypeError"));
assert!(thrown("RegExp.prototype.exec.call({}, 'a')").starts_with("TypeError"));
}
#[test]
fn a_catastrophic_regular_expression_ends_by_fuel() {
let limits = Limits { fuel: 2_000_000, ..Limits::default() };
let src = "var s = ''; for (var i = 0; i < 40; i++) s += 'a'; /(a+)+$/.test(s + 'b')";
let e = fail_with(Realm::with_options(Options::default(), limits.clone()), src);
assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel), "{e:?}");
// The interrupt flag stops it too.
let realm = Realm::with_options(Options::default(), Limits { fuel: u64::MAX, ..Limits::default() });
realm.interrupt().set();
let e = fail_with(realm, src);
assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Interrupted), "{e:?}");
}
#[test]
fn trim_and_date_parsing_take_fuel_for_the_units_they_scan() {
let limits = Limits { fuel: 40_000, ..Limits::default() };
let build = "var s = ' '; for (var i = 0; i < 20; i++) s = s + s; s.length";
assert!(Realm::with_options(Options::default(), limits.clone()).run(build).is_ok());
for scan in ["s.trim()", "Date.parse(s)", "new Date(s)"] {
let e = fail_with(Realm::with_options(Options::default(), limits.clone()), &format!("{build}; {scan}"));
assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel), "{scan}: {e:?}");
}
}
#[test]
fn global_replace_counts_its_matches_against_the_heap() {
let src = "var s = 'ab'; for (var i = 0; i < 17; i++) s = s + s; s.replace(/./g, '').length";
let e = fail_with(Realm::with_options(Options::default(), Limits { heap_bytes: 4 << 20, ..Limits::default() }), src);
assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{e:?}");
assert_eq!(run(src), JsValue::Number(0.0));
}
#[test]
fn replace_holds_its_pieces_and_checks_the_result_as_it_grows() {
// Each `$&` gives a piece for every match: the pieces count against the heap, so the run ends
// with the heap limit long before the 4,194,304 pieces would take the fuel.
let limits = Limits { heap_bytes: 4 << 20, fuel: 500_000, ..Limits::default() };
let src = "var s = 'a'; for (var i = 0; i < 12; i++) s += s; var t = '$&'; for (var i = 0; i < 10; i++) t += t; s.replace(/a/g, t).length";
let e = fail_with(Realm::with_options(Options::default(), limits), src);
assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{e:?}");
// A long literal pattern is not copied for each match, and the result's length is checked as
// the pieces are added, so the run ends with the heap limit before the fuel for 16,384
// copies of the pattern is spent.
let limits = Limits { heap_bytes: 4 << 20, fuel: 1_000_000, ..Limits::default() };
let src = "var s = 'a'; for (var i = 0; i < 14; i++) s += s; var t = 'x'; for (var i = 0; i < 11; i++) t += t; s.replace(/a/g, t).length";
let e = fail_with(Realm::with_options(Options::default(), limits), src);
assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{e:?}");
// The parts of the pattern give the same result as before.
shows("'abcabc'.replace(/(b)(c)/g, '[$$|$&|$`|$\\'|$2$1|$3|$0|$10]')", "a[$|bc|a|abc|cb|$3|$0|b0]a[$|bc|abca||cb|$3|$0|b0]");
}
#[test]
fn flattening_a_rope_counts_against_the_heap() {
// Three ropes of about 524,288 units, each 1 MiB once flat, with a 3 MiB heap.
let build = "var s = 'x'; for (var i = 0; i < 19; i++) s = s + s; var t = s + 'y', u = s + 'z';";
let limits = Limits { heap_bytes: 3 << 20, ..Limits::default() };
let built = Realm::with_options(Options::default(), limits.clone()).run(&format!("{build} s.length + t.length + u.length"));
assert_eq!(built, Ok(JsValue::Number(1_572_866.0)));
for read in [
"charAt(0)",
"charCodeAt(0)",
"indexOf('q')",
"lastIndexOf('q')",
"localeCompare('a')",
"slice(0, 1)",
"substring(0, 1)",
"substr(0, 1)",
"trim()",
"match(/q/)",
"search(/q/)",
"replace(/q/, '')",
"replace('q', '')",
] {
let src = format!("{build} s.{read}; t.{read}; u.{read}; 0");
let e = fail_with(Realm::with_options(Options::default(), limits.clone()), &src);
assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{read}: {e:?}");
}
for call in ["/q/.exec(X)", "/q/.test(X)", "Date.parse(X)", "new RegExp(X)"] {
let src = format!("{build} {}; {}; {}; 0", call.replace('X', "s"), call.replace('X', "t"), call.replace('X', "u"));
let e = fail_with(Realm::with_options(Options::default(), limits.clone()), &src);
assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{call}: {e:?}");
}
}
#[test]
fn compiling_a_pattern_counts_against_the_heap() {
let src = "var s = '[\\\\s\\\\S]a*'; for (var i = 0; i < 14; i++) s = s + s; new RegExp(s).source.length";
let e = fail_with(Realm::with_options(Options::default(), Limits { heap_bytes: 2 << 20, ..Limits::default() }), src);
assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{e:?}");
assert_eq!(run(src), JsValue::Number(131_072.0));
// The compiler stops while it reads the pattern, before the code it builds passes the room.
let pattern = "[\\s\\S]a*".repeat(1000);
assert!(stopped_by(&compile_within(&pattern, 400 * (ITEM_BYTES + INST_BYTES), u64::MAX).0, LimitKind::Heap));
assert!(compile_within(&pattern, usize::MAX, u64::MAX).0.is_ok());
// A literal pattern takes what its instructions hold, not the most a unit can take.
assert!(compile_within(&"a".repeat(10_000), 20_000 * (ITEM_BYTES + INST_BYTES), u64::MAX).0.is_ok());
// A class's ranges count while they are read, even when they collapse into one range.
assert!(stopped_by(&compile_within(&format!("[{}]", "a".repeat(10_000)), 1000, u64::MAX).0, LimitKind::Heap));
// So do the ranges of class escapes, and each atom and range read takes fuel.
let escapes = format!("[{}]", "\\w".repeat(10_000));
assert!(stopped_by(&compile_within(&escapes, 64 << 10, u64::MAX).0, LimitKind::Heap));
assert!(stopped_by(&compile_within(&escapes, usize::MAX, 20_000).0, LimitKind::Fuel));
// The lists count with the room they reserve as they double: 4097 literal units hold room for
// 8192 items, and the flattened code after the last unit adds 4098 instructions.
let literal = "a".repeat(4097);
let (compiled, bytes, _) = compile_within(&literal, usize::MAX, u64::MAX);
assert!(compiled.is_ok());
assert!(bytes >= 8192 * ITEM_BYTES + 4098 * INST_BYTES, "{bytes}");
assert!(stopped_by(&compile_within(&literal, 8192 * ITEM_BYTES + 4000 * INST_BYTES, u64::MAX).0, LimitKind::Heap));
}
#[test]
fn compiling_a_pattern_takes_fuel_for_each_scan_of_it() {
// The scan for capturing groups takes a unit of fuel for each unit of the pattern, and so does
// the compile itself.
let literal = "a".repeat(100_000);
let (compiled, _, fuel) = compile_within(&literal, usize::MAX, u64::MAX);
assert!(compiled.is_ok());
assert!(fuel >= 200_000, "{fuel}");
assert!(stopped_by(&compile_within(&literal, usize::MAX, 150_000).0, LimitKind::Fuel));
// The syntax check before compiling takes a unit for each unit too.
let mut realm = Realm::new();
let interp = realm.interp();
let (source, flags) = (crate::value::Str::from(literal.as_str()), crate::value::Str::from(""));
interp.fuel = 1 << 40;
assert!(crate::regexp_builtins::new_regexp(interp, &source, &flags).is_ok());
let used = (1u64 << 40) - interp.fuel;
assert!(used >= 300_000, "{used}");
interp.fuel = 250_000;
assert!(matches!(crate::regexp_builtins::new_regexp(interp, &source, &flags), Err(crate::interp::Abrupt::Limit(LimitKind::Fuel))));
}
#[test]
fn held_bytes_leave_the_heap_total_when_their_holder_is_dropped() {
let mut realm = Realm::new();
let interp = realm.interp();
interp.collect_for_room();
let live = interp.heap.bytes;
interp.limits.heap_bytes = live + 10_000;
let mut held = crate::interp::HeldBytes::new(interp);
assert!(held.add(interp, 6000).is_ok());
assert_eq!(interp.heap.bytes, live + 6000);
assert!(matches!(crate::interp::HeldBytes::new(interp).add(interp, 6000), Err(crate::interp::Abrupt::Limit(LimitKind::Heap))));
drop(held);
// The next check gives the bytes back without a collection.
assert!(interp.reserve_heap(0).is_ok());
assert_eq!(interp.heap.bytes, live);
let mut again = crate::interp::HeldBytes::new(interp);
assert!(again.add(interp, 6000).is_ok());
assert_eq!(interp.heap.bytes, live + 6000);
}
/// Compiles `pattern` with no flags through a charge that allows `heap` bytes and `fuel` units,
/// and returns the result with the bytes and fuel the compiler asked for.
fn compile_within(pattern: &str, heap: usize, fuel: u64) -> (Result<crate::regexp_vm::Regex, crate::regexp_vm::CompileFail>, usize, u64) {
use crate::regexp_vm::Cost;
let units: Vec<u16> = pattern.encode_utf16().collect();
let flags = crate::regexp_vm::parse_flags(&[]).unwrap();
let (mut bytes, mut used) = (0usize, 0u64);
let result = crate::regexp_vm::compile(&units, flags, 200, &mut |cost| {
match cost {
Cost::Heap(n) => bytes = bytes.saturating_add(n),
Cost::Fuel(n) => used = used.saturating_add(n),
}
if bytes > heap {
return Err(crate::interp::Abrupt::Limit(LimitKind::Heap));
}
if used > fuel {
return Err(crate::interp::Abrupt::Limit(LimitKind::Fuel));
}
Ok(())
});
(result, bytes, used)
}
/// Whether compiling stopped with the limit `kind`.
fn stopped_by(result: &Result<crate::regexp_vm::Regex, crate::regexp_vm::CompileFail>, kind: LimitKind) -> bool {
matches!(result, Err(crate::regexp_vm::CompileFail::Abrupt(crate::interp::Abrupt::Limit(k))) if *k == kind)
}
#[test]
fn compiling_a_pattern_collects_garbage_when_the_heap_is_full() {
let mut realm = Realm::with_options(Options::default(), Limits { heap_bytes: 256 << 10, ..Limits::default() });
let src = "var n = 0; for (var i = 0; i < 5000; i++) { var junk = 'x' + i + 'y' + i; new RegExp('(a|b)c[d-f]'); n++; } n";
assert_eq!(run_in(&mut realm, src), JsValue::Number(5000.0));
}
#[test]
fn a_constructed_source_counts_against_the_heap_before_it_is_built() {
let mut realm = Realm::new();
let interp = realm.interp();
interp.collect_for_room();
let live = interp.heap.bytes;
let pattern = vec![0x2028u16; 10_000];
interp.limits.heap_bytes = live + 50_000;
assert!(matches!(crate::regexp_builtins::source_text(interp, &pattern), Err(crate::interp::Abrupt::Limit(LimitKind::Heap))));
interp.limits.heap_bytes = live + (1 << 20);
let Ok(source) = crate::regexp_builtins::source_text(interp, &pattern) else { panic!("the source fits") };
assert_eq!(source.flat().len(), 60_000);
assert_eq!(string("String(new RegExp('a/\\\\/[/]\\n\\r\\u2028\\u2029'))"), "/a\\/\\/[/]\\n\\r\\u2028\\u2029/");
// A backslash before a line terminator reads back as the terminator's escape.
let escaped = "var r = new RegExp(String.fromCharCode(92, 10, 124, 92, 0x2028)); var back = eval(String(r));";
assert_eq!(
string(&format!("{escaped} String(r) + ' ' + r.test('\\n') + back.test('\\n') + back.test('\\u2028') + back.test('x')")),
"/\\n|\\u2028/ truetruetruefalse"
);
}
#[test]
fn the_matcher_state_counts_against_the_heap() {
let mut realm = Realm::with_options(Options::default(), Limits { heap_bytes: 1 << 20, ..Limits::default() });
let Ok(re) = compile_within(&"(a)".repeat(1000), usize::MAX, u64::MAX).0 else { panic!("the pattern compiles") };
let interp = realm.interp();
interp.collect_for_room();
let live = interp.heap.bytes;
interp.limits.heap_bytes = live + 1000;
assert!(matches!(crate::regexp_vm::Matcher::new(&re, interp), Err(crate::interp::Abrupt::Limit(LimitKind::Heap))));
interp.limits.heap_bytes = 1 << 20;
assert!(crate::regexp_vm::Matcher::new(&re, interp).is_ok());
// The stack takes only the room left: with room for the state and no more, backtracking ends
// with the heap limit.
let Ok(re) = compile_within("a*b", usize::MAX, u64::MAX).0 else { panic!("the pattern compiles") };
let input: Vec<u16> = "aaaaaaaaaa".encode_utf16().collect();
interp.limits.heap_bytes = live + 64;
let Ok(mut m) = crate::regexp_vm::Matcher::new(&re, interp) else { panic!("the state fits") };
assert!(matches!(m.search(interp, &input, 0), Err(crate::interp::Abrupt::Limit(LimitKind::Heap))));
drop(m);
interp.limits.heap_bytes = 1 << 20;
let Ok(mut m) = crate::regexp_vm::Matcher::new(&re, interp) else { panic!("the state fits") };
assert!(matches!(m.search(interp, &input, 0), Ok(false)));
// What the matcher holds is on the heap while it lives, so other allocations cannot use the
// same room, and a collection after it is dropped takes it off again.
let held = m.held_bytes();
assert!(held > 0);
assert!(interp.heap.bytes >= live + held, "{} < {live} + {held}", interp.heap.bytes);
interp.collect_for_room();
assert!(interp.heap.bytes >= live + held, "{} < {live} + {held}", interp.heap.bytes);
drop(m);
interp.collect_for_room();
assert_eq!(interp.heap.bytes, live);
}
#[test]
fn a_backreference_takes_fuel_for_the_units_it_compares() {
let mut realm = Realm::with_options(Options::default(), Limits::default());
let interp = realm.interp();
let mut fuel_for = |pattern: &str, input: &str, want: bool| {
let input: Vec<u16> = input.encode_utf16().collect();
let Ok(re) = compile_within(pattern, usize::MAX, u64::MAX).0 else { panic!("the pattern compiles") };
let Ok(mut m) = crate::regexp_vm::Matcher::new(&re, interp) else { panic!("the state fits") };
interp.fuel = 1 << 40;
assert!(matches!(m.match_at(interp, &input, 0), Ok(hit) if hit == want), "{pattern}");
(1u64 << 40) - interp.fuel
};
// The same 20,000 iterations, each running a one-unit backreference instead of a character:
// each comparison takes a step besides the instruction's own.
let a = "a".repeat(20_001);
let (by_char, by_backref) = (fuel_for("(a)(?:a)*$", &a, true), fuel_for("(a)(?:\\1)*$", &a, true));
assert!(by_backref >= by_char + 20_000, "{by_backref} < {by_char} + 20000");
// A long comparison takes a step for each 16 units it compares.
let long = fuel_for("^(a{10000})\\1", &a, true);
let plain = fuel_for("^(a{10000})", &a, true);
assert!(long >= plain + 625, "{long} < {plain} + 625");
// One that fails on its first unit takes a step for those 16 units only.
let ab = format!("{}{}", "a".repeat(10_000), "b".repeat(10_000));
let (by_backref, by_char) = (fuel_for("^(a{10000})\\1", &ab, false), fuel_for("^(a{10000})c", &ab, false));
assert!(by_backref <= by_char + 2, "{by_backref} > {by_char} + 2");
}
#[test]
fn clearing_matcher_state_takes_fuel() {
let limits = Limits { fuel: 500_000, ..Limits::default() };
let groups = "var p = ''; for (var i = 0; i < 1000; i++) p += '(x)'; var s = ''; for (var i = 0; i < 2000; i++) s += 'a';";
// Each iteration of the `*` clears the 1000 groups inside it, though only `a` runs.
let src = format!("{groups} new RegExp('(?:a|' + p + ')*').test(s)");
let e = fail_with(Realm::with_options(Options::default(), limits.clone()), &src);
assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel), "{e:?}");
// Each attempt at a new position clears the state of all 1001 groups.
let src = format!("{groups} new RegExp(p + 'z').test(s.replace(/a/g, 'b'))");
let e = fail_with(Realm::with_options(Options::default(), limits), &src);
assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel), "{e:?}");
}
#[test]
fn deeply_nested_groups_give_a_syntax_error_and_never_overflow_the_stack() {
let deep = |n: usize| format!("{}a{}", "(".repeat(n), ")".repeat(n));
let src = format!("try {{ new RegExp('{}'); 'compiled' }} catch (e) {{ e.name }}", deep(50_000));
assert_eq!(run(&src), JsValue::String("SyntaxError".into()));
// With the depth limit raised, the same nesting compiles and matches on a small stack: the
// compiler and the matcher keep their state on the heap.
let handle = std::thread::Builder::new()
.stack_size(256 << 10)
.spawn(move || {
let options = Options { max_depth: 60_000, ..Options::default() };
let mut realm = Realm::with_options(options, Limits::default());
let src = format!("var m = new RegExp('{}').exec('xa'); m.length + ':' + m[50000]", deep(50_000));
realm.run(&src)
})
.unwrap();
assert_eq!(handle.join().unwrap(), Ok(JsValue::String("50001:a".into())));
}
#[test]
fn the_matcher_stack_counts_against_the_heap() {
let limits = Limits { heap_bytes: 4 << 20, ..Limits::default() };
let src = "var s = 'ab'; for (var i = 0; i < 18; i++) s = s + s; /(?:a|b)*c/.test(s)";
let e = fail_with(Realm::with_options(Options::default(), limits), src);
assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{e:?}");
}
proptest! {
#[test]
fn arbitrary_patterns_and_inputs_never_panic(pattern in "[ab()|*+?{}\\[\\]^$.\\\\dws0-9,:=!-]{0,24}", input in "[ab\\n0-9 ]{0,24}", flags in "[gim]{0,2}") {
let mut realm = Realm::with_options(Options::default(), Limits { fuel: 200_000, ..Limits::default() });
let quote = |s: &str| s.replace('\\', "\\\\").replace('\'', "\\'").replace('\n', "\\n");
let src = format!(
"try {{ var r = new RegExp('{}', '{}'); var s = '{}'; [r.exec(s), s.replace(r, '$1'), s.split(r), s.match(r), s.search(r)].length }} catch (e) {{ e.name }}",
quote(&pattern), flags, quote(&input)
);
match realm.run(&src) {
Ok(_) => {}
Err(e) => prop_assert!(matches!(e.kind, RunErrorKind::Limit(_)), "{src}: {e:?}"),
}
}
}
// ---- Date ----
proptest! {
#[test]
fn arbitrary_date_strings_never_panic(text in "[0-9TZ:.+ ,()a-zA-Z-]{0,32}") {
let mut realm = Realm::new();
let src = format!("var t = Date.parse('{text}'); new Date('{text}').toString(); t");
prop_assert!(realm.run(&src).is_ok(), "{src}");
}
}
fn date_realm(now: f64, offset_minutes: f64) -> Realm {
let mut realm = Realm::new();
realm.set_clock(move || now);
realm.set_time_zone(move |_| offset_minutes * 60_000.0);
realm
}
#[test]
fn dates_read_the_host_clock_and_time_zone() {
// 2026-11-07T12:34:56.789Z.
let now = 1_794_054_896_789.0;
let mut utc = date_realm(now, 0.0);
assert_eq!(run_in(&mut utc, "Date.now()"), JsValue::Number(now));
assert_eq!(run_in(&mut utc, "new Date().getTime()"), JsValue::Number(now));
assert_eq!(run_in(&mut utc, "new Date().toISOString()"), JsValue::String("2026-11-07T12:34:56.789Z".into()));
assert_eq!(run_in(&mut utc, "String(new Date())"), JsValue::String("Sat Nov 07 2026 12:34:56 GMT+0000".into()));
assert_eq!(run_in(&mut utc, "Date()"), JsValue::String("Sat Nov 07 2026 12:34:56 GMT+0000".into()));
assert_eq!(run_in(&mut utc, "new Date().toUTCString()"), JsValue::String("Sat, 07 Nov 2026 12:34:56 GMT".into()));
let mut east = date_realm(now, 90.0);
assert_eq!(run_in(&mut east, "String(new Date())"), JsValue::String("Sat Nov 07 2026 14:04:56 GMT+0130".into()));
assert_eq!(
run_in(&mut east, "var d = new Date(); d.getHours() + ',' + d.getUTCHours() + ',' + d.getMinutes() + ',' + d.getTimezoneOffset()"),
JsValue::String("14,12,4,-90".into())
);
assert_eq!(run_in(&mut east, "new Date(2026, 10, 7, 14, 4, 56, 789).getTime()"), JsValue::Number(now));
// An offset of a day or more, or one that is not finite, counts as 0.
for offset in [f64::MAX, 86_400_000.0, f64::NAN] {
let mut odd = Realm::new();
odd.set_clock(move || now);
odd.set_time_zone(move |_| offset);
assert_eq!(run_in(&mut odd, "var d = new Date(); d.getHours() + ',' + d.getTimezoneOffset()"), JsValue::String("12,0".into()), "{offset}");
}
let mut west = date_realm(now, -300.0);
assert_eq!(run_in(&mut west, "new Date(2026, 0, 31).toDateString()"), JsValue::String("Sat Jan 31 2026".into()));
}
#[test]
fn dates_round_trip_through_their_strings() {
let mut realm = date_realm(1_794_054_896_789.0, -420.0);
for src in [
"var d = new Date(); Date.parse(d.toString()) === Math_floor(d.getTime())",
"var d = new Date(); Date.parse(d.toUTCString()) === Math_floor(d.getTime())",
"var d = new Date(); Date.parse(d.toISOString()) === d.getTime()",
"var d = new Date(-1e14); Date.parse(d.toISOString()) === d.getTime() && Date.parse(d.toString()) === d.getTime()",
"new Date(new Date(5).toISOString()).getTime() === 5",
"Date.UTC(2000, 1, 29) === Date.parse('2000-02-29') && Date.UTC(99, 0) === Date.UTC(1999, 0)",
"Date.UTC(2000) === Date.UTC(2000, 0) && Date.UTC(1970) === 0 && isNaN_(Date.UTC())",
"isNaN_(Date.parse('nonsense')) && isNaN_(new Date(8.64e15 + 1).getTime()) && new Date(8.64e15).getTime() === 8.64e15",
"var d = new Date(2000, 0, 31); d.setMonth(1); d.getMonth() === 2 && d.getDate() === 2",
"var d = new Date(NaN); d.setFullYear(2001); d.getFullYear() === 2001 && d.getMonth() === 0",
"var d = new Date(0); d.setUTCHours(25, 61, 61, 1001); d.toISOString() === '1970-01-02T02:02:02.001Z'",
"var d = new Date(0); d.setMinutes(); isNaN_(d.getTime())",
"var d = new Date(2000, 0, 1); d.setYear(99); d.getFullYear() === 1999 && d.getYear() === 99",
"Date.prototype.toGMTString === Date.prototype.toUTCString && String(new Date(NaN)) === 'Invalid Date'",
"new Date(0).toJSON() === '1970-01-01T00:00:00.000Z' && new Date(NaN).toJSON() === null",
"Object.prototype.toString.call(Date.prototype) === '[object Date]' && isNaN_(Date.prototype.getTime())",
"var d = new Date(0); d + d === d.toString() + d.toString() && d - d === 0 && d == d.toString()",
"isNaN_(Date.parse('1970-00-01')) && isNaN_(Date.parse('1970-13-01')) && isNaN_(Date.parse('1970-02-30'))",
"Date.parse('thu, 01 JAN 1970 00:00:00 gmt') === 0 && Date.parse('jan 2 1970 utc+01:00') === 82800000",
"isNaN_(Date.parse('Jan 1 1970 1:2:3:4 GMT')) && isNaN_(Date.parse('Jan 1 1970 GMT+01000')) && isNaN_(Date.parse('Jan 1 1970 x'))",
] {
let full = format!("function Math_floor(t) {{ return t - t % 1000; }} function isNaN_(n) {{ return n !== n; }} {src}");
assert_eq!(run_in(&mut realm, &full), JsValue::Bool(true), "{src}");
}
let e = fail_with(date_realm(0.0, 0.0), "new Date(NaN).toISOString()");
assert!(e.message.starts_with("RangeError"), "{e:?}");
let e = fail_with(date_realm(0.0, 0.0), "Date.prototype.getTime.call({})");
assert!(e.message.starts_with("TypeError"), "{e:?}");
}
+118 -32
View File
@@ -19,6 +19,11 @@
//! passes when it completes, or, for `negative: phase: runtime`, when it throws an object whose
//! `constructor` is the global its `type:` names. Its counts are printed apart from the parse
//! counts, and its failures go to `run-failures.txt`.
//!
//! A third test runs the tests under `test/built-ins/String`, `Number`, `RegExp` and `Date` the same
//! way, counting the tests that name an ES5 section apart from the other tests without a feature
//! tag; its failures go to `builtins-failures.txt`. The interpreter tests fail when the
//! interpreter panics, when a harness or test file cannot be read, or when no ES5 test runs.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
@@ -203,8 +208,20 @@ const MISSING_BUILTINS: &str = "Array String Number Boolean Math JSON Date RegEx
fromCharCode substring substr split replace match search exec test toUpperCase toLowerCase trim toFixed toPrecision toExponential \
bind now isArray compareArray verifyProperty fnGlobalObject";
/// The stand-in for `String` that the harness's failure messages call.
const HARNESS_STRING: &str = "function String(value) { return '' + value; }";
/// The names in [`MISSING_BUILTINS`] that `String`, `Number`, `RegExp` and `Date` provide.
const TEXT_BUILTINS: &str = "String Number Date RegExp charAt charCodeAt fromCharCode substring substr split replace match search exec test \
toUpperCase toLowerCase trim toFixed toPrecision toExponential now";
/// String methods whose names `Array.prototype` also uses: provided for the tests under
/// `built-ins/String/`, and still missing elsewhere, where the name usually means the array method.
const STRING_METHODS_SHARED_WITH_ARRAY: &str = "indexOf lastIndexOf concat slice";
/// The names in [`MISSING_BUILTINS`] the interpreter still lacks, without those in each of
/// `provided`.
fn missing_builtins<'a>(provided: &[&str]) -> HashSet<&'a str> {
let provided: HashSet<&str> = provided.iter().flat_map(|p| p.split_whitespace()).collect();
MISSING_BUILTINS.split_whitespace().filter(|name| !provided.contains(name)).collect()
}
/// The source names a missing built-in, as an identifier, a property name or a word inside a
/// string literal (code passed to `eval` or `Function`), or does not parse as ES5 (the tests that
@@ -249,32 +266,110 @@ fn es5_language_subset_runs() {
handle.join().unwrap();
}
/// The counts of one run of a set of tests through the interpreter.
#[derive(Default)]
struct RunCounts {
tests: usize,
skipped: usize,
passed: usize,
failed: usize,
panicked: usize,
failures: String,
}
impl RunCounts {
fn summary(&self) -> String {
format!(
"{} tests that need only the built-ins present, {} runs passed, {} failed ({} panicked), {} skipped",
self.tests, self.passed, self.failed, self.panicked, self.skipped
)
}
}
fn run_language_subset(dir: &Path) {
let language = dir.join("test").join("language");
assert!(language.is_dir(), "{} is not a test262 checkout", dir.display());
let missing = missing_builtins(&[TEXT_BUILTINS]);
let (es5, _) = run_tests(dir, &language, &missing, false);
assert!(es5.tests > 0, "no ES5 tests ran under {}", language.display());
let report = dir.join("run-failures.txt");
if let Err(e) = std::fs::write(&report, &es5.failures) {
println!("could not write {}: {e}", report.display());
}
println!(
"test262 ES5 language subset through the interpreter: {} tests that need only the built-ins present, {} runs passed, \
{} failed ({} panicked). Skipped {} tests that need other built-ins, use syntax after ES5 or are asynchronous. Failures: {}",
es5.tests,
es5.passed,
es5.failed,
es5.panicked,
es5.skipped,
report.display()
);
assert_eq!(es5.panicked, 0, "the interpreter panicked; see {}", report.display());
}
#[test]
fn es5_text_builtins_run() {
let Some(dir) = suite_dir() else {
println!("test262 built-ins: SKIPPED (no suite; run cargo xtask test262)");
return;
};
let handle = std::thread::Builder::new().stack_size(64 << 20).spawn(move || run_text_builtins(&dir)).unwrap();
handle.join().unwrap();
}
/// Runs the tests under `test/built-ins/` for `String`, `Number`, `RegExp` and `Date`: those that
/// name an ES5 section and the other tests without a feature tag, counted apart.
fn run_text_builtins(dir: &Path) {
let mut report = String::new();
let mut panicked = 0;
for name in ["String", "Number", "RegExp", "Date"] {
let root = dir.join("test").join("built-ins").join(name);
assert!(root.is_dir(), "{} is not a test262 checkout", dir.display());
let missing =
if name == "String" { missing_builtins(&[TEXT_BUILTINS, STRING_METHODS_SHARED_WITH_ARRAY]) } else { missing_builtins(&[TEXT_BUILTINS]) };
let (es5, later) = run_tests(dir, &root, &missing, true);
assert!(es5.tests > 0, "no ES5 tests ran under {}", root.display());
println!("test262 built-ins/{name}, ES5 tests: {}. Other tests without a feature tag: {}.", es5.summary(), later.summary());
report.push_str(&format!("# built-ins/{name}, ES5 tests\n{}\n# built-ins/{name}, other tests\n{}\n", es5.failures, later.failures));
panicked += es5.panicked + later.panicked;
}
let path = dir.join("builtins-failures.txt");
if let Err(e) = std::fs::write(&path, &report) {
println!("could not write {}: {e}", path.display());
}
println!("test262 built-ins failures: {}", path.display());
assert_eq!(panicked, 0, "the interpreter panicked; see {}", path.display());
}
/// Runs the tests under `root` that have no feature tag and are not module, asynchronous or
/// negative parse tests, each in a new realm after the harness. Returns the counts for the tests
/// that name an ES5 section and, when `later` is set, for the others (otherwise those are left out).
fn run_tests(dir: &Path, root: &Path, missing: &HashSet<&str>, later: bool) -> (RunCounts, RunCounts) {
let harness_dir = dir.join("harness");
// A file that cannot be read gives an empty source.
let read = |p: &Path| String::from_utf8_lossy(&std::fs::read(p).unwrap_or_default()).into_owned();
// `assert.js` formats a failed assertion's message with the global `String`, which the
// interpreter does not provide yet; the stand-in converts with `+` so a failure reports the
// assertion's message instead of a ReferenceError.
let prelude = format!("{HARNESS_STRING}\n{}\n{}\n", read(&harness_dir.join("assert.js")), read(&harness_dir.join("sta.js")));
let missing: HashSet<&str> = MISSING_BUILTINS.split_whitespace().collect();
let read = |p: &Path| {
let bytes = std::fs::read(p).map_err(|e| format!("cannot read {}: {e}", p.display()));
assert!(bytes.is_ok(), "{}", bytes.as_ref().err().map_or("", String::as_str));
String::from_utf8_lossy(&bytes.unwrap_or_default()).into_owned()
};
let prelude = format!("{}\n{}\n", read(&harness_dir.join("assert.js")), read(&harness_dir.join("sta.js")));
let verbose = std::env::var_os("VECTORCRAFT_TEST262_VERBOSE").is_some();
let (mut tests, mut skipped, mut passed, mut failed, mut panicked) = (0usize, 0usize, 0usize, 0usize, 0usize);
let mut failures = String::new();
for path in test_files(&language) {
let (mut es5_counts, mut later_counts) = (RunCounts::default(), RunCounts::default());
for path in test_files(root) {
let source = read(&path);
let meta = meta(&source);
let negative_parse = matches!(meta.negative_phase.as_deref(), Some("parse" | "resolution"));
if !meta.es5 || !meta.features.is_empty() || negative_parse || meta.flags.iter().any(|f| f == "module") {
if (!meta.es5 && !later) || !meta.features.is_empty() || negative_parse || meta.flags.iter().any(|f| f == "module") {
continue;
}
let counts = if meta.es5 { &mut es5_counts } else { &mut later_counts };
let includes: String = meta.includes.iter().map(|i| read(&harness_dir.join(i)) + "\n").collect();
if meta.flags.iter().any(|f| f == "async") || needs_missing_builtin(&source, &missing) || needs_missing_builtin(&includes, &missing) {
skipped += 1;
if meta.flags.iter().any(|f| f == "async") || needs_missing_builtin(&source, missing) || needs_missing_builtin(&includes, missing) {
counts.skipped += 1;
continue;
}
tests += 1;
counts.tests += 1;
let has = |flag: &str| meta.flags.iter().any(|f| f == flag);
let modes: &[bool] = if has("raw") || has("noStrict") {
&[false]
@@ -294,27 +389,18 @@ fn run_language_subset(dir: &Path) {
let negative_type = meta.negative_type.clone().filter(|_| meta.negative_phase.as_deref() == Some("runtime"));
let outcome = std::panic::catch_unwind(|| run_one(&script, negative_type.as_deref()));
match outcome {
Ok(Ok(())) => passed += 1,
Ok(Ok(())) => counts.passed += 1,
Ok(Err(why)) => {
failed += 1;
failures.push_str(&format!("{rel} ({mode}): {why}\n"));
counts.failed += 1;
counts.failures.push_str(&format!("{rel} ({mode}): {why}\n"));
}
Err(_) => {
failed += 1;
panicked += 1;
failures.push_str(&format!("{rel} ({mode}): PANIC\n"));
counts.failed += 1;
counts.panicked += 1;
counts.failures.push_str(&format!("{rel} ({mode}): PANIC\n"));
}
}
}
}
let report = dir.join("run-failures.txt");
if let Err(e) = std::fs::write(&report, &failures) {
println!("could not write {}: {e}", report.display());
}
println!(
"test262 ES5 language subset through the interpreter: {tests} tests that need only the built-ins present, {passed} runs passed, \
{failed} failed ({panicked} panicked). Skipped {skipped} tests that need other built-ins, use syntax after ES5 or are asynchronous. Failures: {}",
report.display()
);
assert_eq!(panicked, 0, "the interpreter panicked; see {}", report.display());
(es5_counts, later_counts)
}
+2
View File
@@ -315,6 +315,8 @@ impl Headless {
/// `app.export {path?, format?, …}`: `document.export` (format defaults to the path's extension).
pub fn export(&mut self, p: &Value) -> Result<Value, String> {
// The key check of `document.export` runs before the file is written.
self.session.check_params("document.export", p).map_err(|e| e.to_string())?;
let roots = self.roots.clone();
file_access::confine(roots.as_ref(), || self.session.execute("document.export", p)).map_err(|e| e.to_string())
}
+28
View File
@@ -16,6 +16,34 @@ fn three_boards() -> Headless {
h
}
/// The tool, `app.export` and `file.export` refuse a key no format takes, at the top level of
/// `options` and inside a PDF object.
#[test]
fn an_option_no_format_takes_is_an_error() {
let mut h = Headless::new();
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 50, "height": 30}})).unwrap();
for (options, named) in [
(json!({"standard": "pdfX4", "noSuchOption": true}), "noSuchOption"),
(json!({"marks": {"useDocumentBleed": true}}), "marks.useDocumentBleed"),
] {
let r = call_tool(&mut h, "export", &json!({"format": "pdf", "options": options}));
assert!(r.is_error && r.content[0]["text"].as_str().unwrap().contains(named), "{r:?}");
let mut params = options.clone();
params["format"] = json!("pdf");
for method in ["app.export", "file.export"] {
let e = if method == "app.export" {
h.call(method, params.clone()).unwrap_err()
} else {
h.call("engine.execute", json!({"command": method, "params": params})).unwrap_err()
};
assert!(e.contains(named), "{method}: {e}");
}
}
// One object can still carry several formats' options.
let r = call_tool(&mut h, "export", &json!({"format": "pdf", "options": {"quality": 80, "styling": "style"}}));
assert!(!r.is_error, "{r:?}");
}
#[test]
fn raster_export_uses_document_resolution_unless_overridden() {
let mut h = Headless::new();
+4 -2
View File
@@ -25,7 +25,9 @@
//! document in a file format of its own. The process-wide [`registry`] holds the installed
//! plug-ins; in [`safe_mode`] none of them loads or runs. A plug-in's parameters ([`manifest`]) come with what the host's dialogs need to show
//! them well: labels, units, groups, help and conditions ([`dialog`]). What a plug-in may do is
//! the set of [`permissions`] its manifest lists, which the user approves when it is installed.
//! the set of [`permissions`] its manifest lists, which the user approves when it is installed. A
//! filter or a live effect may also add menu commands of its own ([`Command`]), which the host
//! runs through `vc_run` with the command's id in `_context`.
#![forbid(unsafe_code)]
pub mod dialog;
@@ -43,7 +45,7 @@ pub mod wat;
pub use dialog::ParamUi;
pub use host::{LogLevel, LogLine, Report};
pub use manifest::{Budget, Kind, Manifest, Param, ParamSpec};
pub use manifest::{Budget, Command, Kind, Manifest, Param, ParamSpec};
pub use runtime::{ABI_VERSION, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Limits, Plugin, heavy_terms};
/// What the host puts before a plug-in's id to name what it adds: its live effect
+287 -4
View File
@@ -20,6 +20,10 @@ pub const MAX_TEXT: usize = 4_096;
pub const MAX_LIST: usize = 256;
/// Most file extensions an exporter may declare.
pub const MAX_EXTENSIONS: usize = 8;
/// Most menu commands a plug-in may add (`commands`).
pub const MAX_COMMANDS: usize = 32;
/// Longest command id, in characters.
pub const MAX_COMMAND_ID: usize = 32;
/// What a plug-in is.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -172,6 +176,30 @@ pub struct Param {
pub ui: ParamUi,
}
/// A menu command a plug-in adds (`commands`): the host lists it under Object › Plug-ins › the
/// plug-in's name (Effect › Plug-ins for a live effect's), runs it as the command
/// `plugin.<plug-in id>.<id>`, and calls the plug-in's `vc_run` with `_context.command` set to
/// `id` and the parameters it declares.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Command {
/// `[a-z][a-z0-9_]*`, at most [`MAX_COMMAND_ID`] characters, unique within the manifest.
pub id: String,
/// The menu label: 1 to 64 printable characters, shown with bidirectional characters escaped
/// ([`crate::permissions::display_text`]).
pub label: String,
/// Parameters in declaration (dialog) order, declared as the manifest's own are.
#[serde(default, deserialize_with = "ordered_params", serialize_with = "params_as_object")]
pub params: Vec<(String, Param)>,
}
impl Command {
/// The label as menus and errors show it: control and bidirectional characters escaped, at
/// most [`crate::permissions::MAX_SHOWN_NAME`] characters.
pub fn shown_label(&self) -> String {
crate::permissions::display_text(self.label.trim(), crate::permissions::MAX_SHOWN_NAME)
}
}
impl From<ParamSpec> for Param {
fn from(spec: ParamSpec) -> Self {
Param { spec, ui: ParamUi::default() }
@@ -260,6 +288,9 @@ pub struct Manifest {
/// How much a run may compute: only a filter may be [`Budget::Heavy`].
#[serde(default, skip_serializing_if = "Budget::is_light")]
pub budget: Budget,
/// The menu commands it adds, in menu order (a filter or a live effect only).
#[serde(default, deserialize_with = "ordered_commands", skip_serializing_if = "Vec::is_empty")]
pub commands: Vec<Command>,
}
impl Manifest {
@@ -288,9 +319,21 @@ impl Manifest {
params: Vec::new(),
extensions: Vec::new(),
budget: Budget::Light,
commands: Vec::new(),
}
}
/// The manifest command `c` runs with: this one with `c`'s label as its name and `c`'s
/// parameters as its own, so dialogs show them and [`Manifest::resolve_params`] checks them.
pub fn for_command(&self, c: &Command) -> Manifest {
Manifest { name: c.label.clone(), params: c.params.clone(), commands: Vec::new(), ..self.clone() }
}
/// The command with id `id`.
pub fn command(&self, id: &str) -> Option<&Command> {
self.commands.iter().find(|c| c.id == id)
}
/// Checks what the module declared: its id, name and text lengths, its extensions, a budget its
/// kind may have (only a filter may be heavy), and each parameter and its dialog layout.
fn validate(&self) -> Result<()> {
@@ -308,7 +351,8 @@ impl Manifest {
return Err(Error::Manifest(format!("`api` is {}; this host implements plug-in API levels 1 to {API_LEVEL}", self.api)));
}
if let Some(names) = &self.permissions
&& let Some(host) = permissions::validate(self.kind, names)?.into_iter().find(|p| permissions::HOST.contains(p))
&& let Some(host) =
permissions::validate(self.kind, !self.commands.is_empty(), names)?.into_iter().find(|p| permissions::HOST.contains(p))
&& self.api < PROGRESS_API
{
let permission = host.name();
@@ -324,6 +368,12 @@ impl Manifest {
return Err(Error::Manifest("an exporter always gets an export's budget: leave `budget` out".into()));
}
}
self.validate_params()?;
self.validate_commands()
}
/// Checks each parameter and its dialog layout.
fn validate_params(&self) -> Result<()> {
for (i, (k, p)) in self.params.iter().enumerate() {
if !valid_key(k) {
return Err(Error::Manifest(format!("invalid parameter name {k:?}")));
@@ -337,6 +387,47 @@ impl Manifest {
Ok(())
}
/// A filter or a live effect lists at most [`MAX_COMMANDS`] commands, each with a distinct
/// id, a printable label and parameters declared as the manifest's own are. A heavy filter
/// lists none. The error names the command: its id, or its place in the list.
fn validate_commands(&self) -> Result<()> {
if self.commands.is_empty() {
return Ok(());
}
if self.kind == Kind::Exporter {
return Err(Error::Manifest("only a filter or a live effect declares `commands`".into()));
}
if self.budget == Budget::Heavy {
return Err(Error::Manifest("a heavy filter runs in the background, so it can't declare `commands`".into()));
}
if self.commands.len() > MAX_COMMANDS {
return Err(Error::Manifest(format!("at most {MAX_COMMANDS} `commands`")));
}
for (i, c) in self.commands.iter().enumerate() {
let name = command_name(i, &c.id);
if !valid_command_id(&c.id) {
return Err(Error::Manifest(format!(
"command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter"
)));
}
let label = c.label.trim();
if label.is_empty() || label.chars().count() > 64 || label.chars().any(char::is_control) {
return Err(Error::Manifest(format!("command {name}: `label` must be 1-64 printable characters")));
}
if self.commands.get(..i).is_some_and(|before| before.iter().any(|b| b.id == c.id)) {
return Err(Error::Manifest(format!("command {name} is declared twice")));
}
self.for_command(c).validate_params().map_err(|e| {
let e = match e {
Error::Manifest(why) => why,
other => other.to_string(),
};
Error::Manifest(format!("command {name}: {e}"))
})?;
}
Ok(())
}
/// An exporter lists 1 to [`MAX_EXTENSIONS`] distinct extensions of 1 to 16 lower-case
/// letters and digits; other kinds list none.
fn validate_extensions(&self) -> Result<()> {
@@ -389,11 +480,14 @@ impl Manifest {
self.params.iter().map(|(k, p)| (k.clone(), p.spec.default_value())).collect()
}
/// What the plug-in may do: the permissions it lists, else the ones its kind implies.
/// What the plug-in may do: the permissions it lists, else every one its kind may list
/// ([`Permission::allowed`]: a live effect with commands also changes the document and keeps
/// data with it).
pub fn permissions(&self) -> Vec<Permission> {
let commands = !self.commands.is_empty();
match &self.permissions {
Some(names) => permissions::validate(self.kind, names).unwrap_or_default(),
None => Permission::implied(self.kind).to_vec(),
Some(names) => permissions::validate(self.kind, commands, names).unwrap_or_default(),
None => Permission::allowed(self.kind, commands).to_vec(),
}
}
@@ -492,6 +586,119 @@ fn ordered_params<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result
d.deserialize_any(V)
}
/// Reads the `commands` list one entry at a time from the manifest itself, so each command's
/// `params` keep their declaration order and a repeated parameter is refused. A malformed entry
/// is an error that names it (its id when the id comes before the fault, otherwise its place in
/// the list), and a list is refused at its first entry past [`MAX_COMMANDS`].
fn ordered_commands<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Vec<Command>, D::Error> {
use serde::de::{Error as _, IgnoredAny, MapAccess, SeqAccess, Visitor};
/// A command's `params`, read in declaration order.
struct Params(Vec<(String, Param)>);
impl<'de> Deserialize<'de> for Params {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
ordered_params(d).map(Params)
}
}
/// The entry at place `.0` in the list.
struct Entry(usize);
impl<'de> serde::de::DeserializeSeed<'de> for Entry {
type Value = Command;
fn deserialize<D: serde::Deserializer<'de>>(self, d: D) -> std::result::Result<Command, D::Error> {
d.deserialize_any(self)
}
}
impl<'de> Visitor<'de> for Entry {
type Value = Command;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "command [{}] as {{id, label, params?}}", self.0)
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> std::result::Result<Command, A::Error> {
let (mut id, mut label, mut params) = (None::<String>, None::<String>, None::<Params>);
let named = |id: &Option<String>, e: &dyn std::fmt::Display| {
A::Error::custom(format!("command {}: {e}", command_name(self.0, id.as_deref().unwrap_or_default())))
};
while let Some(key) = map.next_key::<String>().map_err(|e| named(&id, &e))? {
let repeated = match key.as_str() {
"id" if id.is_none() => {
id = Some(map.next_value().map_err(|e| named(&id, &e))?);
false
}
"label" if label.is_none() => {
label = Some(map.next_value().map_err(|e| named(&id, &e))?);
false
}
"params" if params.is_none() => {
params = Some(map.next_value().map_err(|e| named(&id, &e))?);
false
}
"id" | "label" | "params" => true,
_ => {
map.next_value::<IgnoredAny>().map_err(|e| named(&id, &e))?;
false
}
};
if repeated {
return Err(named(&id, &format_args!("duplicate field `{key}`")));
}
}
let Some(id) = id else {
return Err(named(&None, &"missing field `id`"));
};
let Some(label) = label else {
return Err(named(&Some(id), &"missing field `label`"));
};
Ok(Command { id, label, params: params.map(|p| p.0).unwrap_or_default() })
}
}
struct List;
impl<'de> Visitor<'de> for List {
type Value = Vec<Command>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("`commands` as a list of {id, label, params?}")
}
fn visit_unit<E: serde::de::Error>(self) -> std::result::Result<Self::Value, E> {
Ok(Vec::new())
}
fn visit_map<A: MapAccess<'de>>(self, _: A) -> std::result::Result<Self::Value, A::Error> {
Err(A::Error::custom("`commands` must be a list of {id, label, params?}"))
}
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error> {
let mut out = Vec::new();
while out.len() < MAX_COMMANDS {
match seq.next_element_seed(Entry(out.len()))? {
Some(c) => out.push(c),
None => return Ok(out),
}
}
match seq.next_element::<IgnoredAny>()? {
Some(_) => Err(A::Error::custom(format!("at most {MAX_COMMANDS} `commands`"))),
None => Ok(out),
}
}
}
d.deserialize_any(List)
}
/// How errors name the command at place `i` with id `id`: the id in backticks, escaped and capped
/// as prompts show text, or `[i]` when it has none.
fn command_name(i: usize, id: &str) -> String {
match id {
"" => format!("[{i}]"),
id => format!("`{}`", crate::permissions::display_text(id, crate::permissions::MAX_SHOWN_FIELD)),
}
}
/// `[a-z][a-z0-9_]*`, 1 to [`MAX_COMMAND_ID`] characters.
fn valid_command_id(id: &str) -> bool {
id.len() <= MAX_COMMAND_ID
&& id.bytes().next().is_some_and(|b| b.is_ascii_lowercase())
&& id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
}
fn params_as_object<S: serde::Serializer>(p: &[(String, Param)], s: S) -> std::result::Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut m = s.serialize_map(Some(p.len()))?;
@@ -650,6 +857,16 @@ mod tests {
assert!(serde_json::to_value(&plain).unwrap().get("permissions").is_none());
let effect = Manifest::parse(br#"{"id":"a","name":"x","kind":"effect"}"#).unwrap();
assert_eq!(effect.permissions(), [Permission::DocRead, Permission::EffectRender]);
// A live effect's commands may change the document: one with commands gets `doc.write`
// and `state` too, and may list them.
let commands = br#"{"id":"a","name":"x","kind":"effect","commands":[{"id":"c","label":"C"}]}"#;
let with_commands = Manifest::parse(commands).unwrap();
assert_eq!(with_commands.permissions(), [Permission::DocRead, Permission::DocWrite, Permission::State, Permission::EffectRender]);
let listing =
br#"{"id":"a","name":"x","kind":"effect","permissions":["doc.read","effect.render","doc.write"],"commands":[{"id":"c","label":"C"}]}"#;
assert_eq!(Manifest::parse(listing).unwrap().permissions(), [Permission::DocRead, Permission::DocWrite, Permission::EffectRender]);
let without = br#"{"id":"a","name":"x","kind":"effect","permissions":["doc.read","effect.render","doc.write"]}"#;
assert!(Manifest::parse(without).unwrap_err().to_string().contains("`doc.write` doesn't apply"));
let exporter = Manifest::parse(br#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"]}"#).unwrap();
assert_eq!(exporter.permissions(), [Permission::DocRead, Permission::ExportWrite]);
assert!(!Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","permissions":[]}"#).unwrap().asks_permissions());
@@ -680,6 +897,72 @@ mod tests {
}
}
/// A filter or a live effect lists menu commands with their own parameters; a `menu` key is
/// ignored. A malformed entry, a bad or repeated id, a label that isn't printable and too many
/// commands are refused, and the error names the command.
#[test]
fn commands_are_checked_and_errors_name_them() {
let m = Manifest::parse(
br#"{"id":"org.example.tidy","name":"Tidy","kind":"filter","commands":[
{"id":"tidy_all","label":"Tidy All","menu":"File"},
{"id":"grow","label":"Grow","params":{"mode":{"type":"bool"},"by":{"type":"number","min":0,"max":10,"default":2}}}]}"#,
)
.unwrap();
assert_eq!(m.commands.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), ["tidy_all", "grow"]);
let grow = m.command("grow").unwrap();
assert_eq!(grow.params.iter().map(|p| p.0.as_str()).collect::<Vec<_>>(), ["mode", "by"], "declaration order");
let run = m.for_command(grow);
assert_eq!((run.name.as_str(), run.id.as_str()), ("Grow", "org.example.tidy"));
assert_eq!(Value::Object(run.resolve_params(&json!({"by": 50})).unwrap()), json!({"by": 10.0, "mode": false}));
let written = serde_json::to_value(&m).unwrap();
assert_eq!(written["commands"][1]["params"]["by"]["max"], json!(10.0));
assert!(written["commands"][0].get("menu").is_none(), "a menu key is not kept");
assert!(serde_json::to_value(Manifest::parse(br#"{"id":"a","name":"x","kind":"filter"}"#).unwrap()).unwrap().get("commands").is_none());
let effect = Manifest::parse(br#"{"id":"a","name":"x","kind":"effect","commands":[{"id":"c","label":"C"}]}"#).unwrap();
assert_eq!(effect.commands.len(), 1);
let label = |l: &str| Command { id: "c".into(), label: l.into(), params: Vec::new() }.shown_label();
assert_eq!(label("Flip\u{202E}txt.exe"), "Flip\\u{202E}txt.exe");
let many: Vec<String> = (0..=MAX_COMMANDS).map(|i| format!(r#"{{"id":"c{i}","label":"C"}}"#)).collect();
let too_many = format!(r#"{{"id":"a","name":"x","kind":"filter","commands":[{}]}}"#, many.join(","));
for (bad, says) in [
(r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"ok","label":"A"},{"label":"B"}]}"#, "command [1]: missing field `id`"),
(r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"nolabel"}]}"#, "command `nolabel`: missing field `label`"),
(r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"Upper","label":"A"}]}"#, "command `Upper`: use an id"),
(r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"9lives","label":"A"}]}"#, "command `9lives`: use an id"),
(r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"a.b","label":"A"}]}"#, "command `a.b`: use an id"),
(r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"","label":"A"}]}"#, "command [0]: use an id"),
(r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":" "}]}"#, "command `x`: `label`"),
(r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"a\nb"}]}"#, "command `x`: `label`"),
(r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"A"},{"id":"x","label":"B"}]}"#, "command `x` is declared twice"),
(
r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"A","params":{"r":{"type":"number","min":5,"max":1,"default":1}}}]}"#,
"command `x`: parameter `r`: bad range",
),
(
r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"A","params":{"id":{"type":"bool"}}}]}"#,
"command `x`: invalid parameter",
),
(
r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"A","params":{"r":{"type":"bool"},"r":{"type":"int"}}}]}"#,
"command `x`: parameter `r` is declared twice",
),
(r#"{"id":"a","name":"x","kind":"filter","commands":[1]}"#, "command [0]"),
(r#"{"id":"a","name":"x","kind":"filter","commands":{"id":"x"}}"#, "must be a list"),
(r#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"],"commands":[{"id":"x","label":"A"}]}"#, "only a filter or a live effect"),
(r#"{"id":"a","name":"x","kind":"filter","budget":"heavy","commands":[{"id":"x","label":"A"}]}"#, "heavy filter"),
(too_many.as_str(), "at most 32 `commands`"),
] {
let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string();
assert!(e.contains(says), "{bad}: {e}");
}
let long_id = format!(r#"{{"id":"a","name":"x","kind":"filter","commands":[{{"id":"{}","label":"A"}}]}}"#, "c".repeat(MAX_COMMAND_ID + 1));
assert!(Manifest::parse(long_id.as_bytes()).is_err());
let hidden =
Manifest::parse("{\"id\":\"a\",\"name\":\"x\",\"kind\":\"filter\",\"commands\":[{\"id\":\"x\u{202E}\",\"label\":\"A\"}]}".as_bytes());
assert!(hidden.unwrap_err().to_string().contains("command `x\\u{202E}`"), "an id in an error is escaped");
}
#[test]
fn strings_and_number_lists_resolve_like_the_other_types() {
let m = Manifest::parse(
+58 -32
View File
@@ -115,10 +115,10 @@ impl Permission {
}
}
/// May a plug-in of `kind` list it: one its kind implies, or one of the host imports' ([`HOST`]),
/// which any kind may list.
pub fn applies(self, kind: Kind) -> bool {
HOST.contains(&self) || Permission::implied(kind).contains(&self)
/// May a plug-in of `kind` that declares `commands` or not list it: one [`Permission::allowed`]
/// gives it, or one of the host imports' ([`HOST`]), which any kind may list.
pub fn applies(self, kind: Kind, commands: bool) -> bool {
HOST.contains(&self) || Permission::allowed(kind, commands).contains(&self)
}
/// The permissions a manifest of `kind` without `permissions` gets. A manifest may also list
@@ -131,6 +131,17 @@ impl Permission {
}
}
/// The permissions a plug-in of `kind` that declares `commands` or not may list besides
/// `progress`, which are also the ones its manifest gets without `permissions`: those `kind`
/// implies, and for a live effect with commands also `doc.write` and `state`, which only its
/// commands use (drawing the effect changes no object and keeps no data).
pub fn allowed(kind: Kind, commands: bool) -> &'static [Permission] {
match kind {
Kind::Effect if commands => &[Permission::DocRead, Permission::DocWrite, Permission::State, Permission::EffectRender],
_ => Permission::implied(kind),
}
}
/// The permissions a plug-in of `kind` can't work without, which its list must hold: a live
/// effect and an exporter are always handed objects, and only draw or write. A filter may
/// leave any of its permissions out.
@@ -142,10 +153,11 @@ impl Permission {
}
}
/// Checks the `permissions` a manifest of `kind` lists → them, in the order [`ALL`] has. A name
/// that is not a permission, a reserved one, one that doesn't apply to the kind, one listed twice
/// and a list without what the kind needs are errors that name it.
pub(crate) fn validate(kind: Kind, names: &[String]) -> Result<Vec<Permission>> {
/// Checks the `permissions` a manifest of `kind` lists → them, in the order [`ALL`] has; `commands`
/// is whether it declares commands ([`Permission::allowed`]). A name that is not a permission, a
/// reserved one, one that doesn't apply to the kind, one listed twice and a list without what the
/// kind needs are errors that name it.
pub(crate) fn validate(kind: Kind, commands: bool, names: &[String]) -> Result<Vec<Permission>> {
if names.len() > MAX_PERMISSIONS {
return Err(Error::Manifest(format!("at most {MAX_PERMISSIONS} `permissions`")));
}
@@ -158,7 +170,7 @@ pub(crate) fn validate(kind: Kind, names: &[String]) -> Result<Vec<Permission>>
}
return Err(Error::Manifest(format!("unknown permission `{shown}`")));
};
if !p.applies(kind) {
if !p.applies(kind, commands) {
return Err(Error::Manifest(format!("permission `{shown}` doesn't apply to a plug-in of kind `{}`", kind.name())));
}
if out.contains(&p) {
@@ -261,41 +273,55 @@ mod tests {
#[test]
fn lists_are_checked_against_the_kind() {
let list = |names: &[&str]| names.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(validate(Kind::Filter, &list(&["doc.write", "doc.read"])).unwrap(), [Permission::DocRead, Permission::DocWrite]);
assert_eq!(validate(Kind::Filter, &[]).unwrap(), []);
assert!(validate(Kind::Effect, &list(&["doc.read", "effect.render"])).is_ok());
assert!(validate(Kind::Exporter, &list(&["export.write", "doc.read"])).is_ok());
assert_eq!(validate(Kind::Filter, false, &list(&["doc.write", "doc.read"])).unwrap(), [Permission::DocRead, Permission::DocWrite]);
assert_eq!(validate(Kind::Filter, false, &[]).unwrap(), []);
assert!(validate(Kind::Effect, false, &list(&["doc.read", "effect.render"])).is_ok());
assert!(validate(Kind::Exporter, false, &list(&["export.write", "doc.read"])).is_ok());
// Any kind may list `progress`, which no kind implies.
assert_eq!(validate(Kind::Filter, &list(&["progress", "doc.read"])).unwrap(), [Permission::DocRead, Permission::Progress]);
assert!(validate(Kind::Effect, &list(&["doc.read", "effect.render", "progress"])).is_ok());
assert!(validate(Kind::Exporter, &list(&["progress", "export.write", "doc.read"])).is_ok());
assert_eq!(validate(Kind::Filter, false, &list(&["progress", "doc.read"])).unwrap(), [Permission::DocRead, Permission::Progress]);
assert!(validate(Kind::Effect, false, &list(&["doc.read", "effect.render", "progress"])).is_ok());
assert!(validate(Kind::Effect, true, &list(&["doc.read", "effect.render", "progress"])).is_ok());
assert!(validate(Kind::Exporter, false, &list(&["progress", "export.write", "doc.read"])).is_ok());
assert!([Kind::Filter, Kind::Effect, Kind::Exporter].iter().all(|k| !Permission::implied(*k).contains(&Permission::Progress)));
// So may `settings` and `cache`, which no kind implies either.
assert_eq!(
validate(Kind::Effect, &list(&["cache", "settings", "doc.read", "effect.render"])).unwrap(),
validate(Kind::Effect, false, &list(&["cache", "settings", "doc.read", "effect.render"])).unwrap(),
[Permission::DocRead, Permission::EffectRender, Permission::Settings, Permission::Cache]
);
assert!(validate(Kind::Exporter, &list(&["settings", "export.write", "doc.read"])).is_ok());
assert_eq!(validate(Kind::Filter, &list(&["cache"])).unwrap(), [Permission::Cache]);
assert!(validate(Kind::Exporter, false, &list(&["settings", "export.write", "doc.read"])).is_ok());
assert_eq!(validate(Kind::Filter, false, &list(&["cache"])).unwrap(), [Permission::Cache]);
for kind in [Kind::Filter, Kind::Effect, Kind::Exporter] {
assert!(HOST.iter().all(|p| p.applies(kind) && !Permission::implied(kind).contains(p)));
for commands in [false, true] {
assert!(HOST.iter().all(|p| p.applies(kind, commands) && !Permission::implied(kind).contains(p)));
}
}
for (kind, names, says) in [
(Kind::Filter, &["doc.read", "clipboard"][..], "unknown permission `clipboard`"),
(Kind::Filter, &["ui.panel"], "`ui.panel` is reserved"),
(Kind::Filter, &["progress", "progress"], "`progress` is listed twice"),
(Kind::Filter, &["cache", "cache"], "`cache` is listed twice"),
(Kind::Filter, &["storage"], "unknown permission `storage`"),
(Kind::Filter, &["export.write"], "doesn't apply to a plug-in of kind `filter`"),
(Kind::Exporter, &["doc.read", "export.write", "doc.read"], "`doc.read` is listed twice"),
(Kind::Effect, &["doc.read"], "needs the permission `effect.render`"),
(Kind::Exporter, &["export.write"], "needs the permission `doc.read`"),
// A live effect with commands may also change the document and keep data with it.
assert_eq!(
validate(Kind::Effect, true, &list(&["state", "effect.render", "doc.write", "doc.read"])).unwrap(),
[Permission::DocRead, Permission::DocWrite, Permission::State, Permission::EffectRender]
);
assert_eq!(Permission::allowed(Kind::Effect, false), Permission::implied(Kind::Effect));
assert_eq!(Permission::allowed(Kind::Filter, true), Permission::implied(Kind::Filter));
for (kind, commands, names, says) in [
(Kind::Filter, false, &["doc.read", "clipboard"][..], "unknown permission `clipboard`"),
(Kind::Filter, false, &["ui.panel"], "`ui.panel` is reserved"),
(Kind::Filter, false, &["progress", "progress"], "`progress` is listed twice"),
(Kind::Filter, false, &["export.write"], "doesn't apply to a plug-in of kind `filter`"),
(Kind::Exporter, false, &["doc.read", "export.write", "doc.read"], "`doc.read` is listed twice"),
(Kind::Effect, false, &["doc.read"], "needs the permission `effect.render`"),
(Kind::Effect, false, &["doc.read", "effect.render", "doc.write"], "`doc.write` doesn't apply to a plug-in of kind `effect`"),
(Kind::Effect, true, &["doc.read", "doc.write"], "needs the permission `effect.render`"),
(Kind::Effect, true, &["doc.read", "effect.render", "export.write"], "`export.write` doesn't apply"),
(Kind::Exporter, true, &["doc.read", "export.write", "doc.write"], "`doc.write` doesn't apply"),
(Kind::Exporter, false, &["export.write"], "needs the permission `doc.read`"),
(Kind::Filter, false, &["cache", "cache"], "`cache` is listed twice"),
(Kind::Filter, false, &["storage"], "unknown permission `storage`"),
] {
let e = validate(kind, &list(names)).unwrap_err().to_string();
let e = validate(kind, commands, &list(names)).unwrap_err().to_string();
assert!(e.contains(says), "{names:?}: {e}");
}
let many = list(&["doc.read"; MAX_PERMISSIONS + 1]);
assert!(validate(Kind::Filter, &many).unwrap_err().to_string().contains("at most"));
assert!(validate(Kind::Filter, false, &many).unwrap_err().to_string().contains("at most"));
}
/// Control, bidirectional override and separator characters show as escapes, so a name can't
+34 -2
View File
@@ -1,6 +1,9 @@
use super::options::{AntiAlias, RenderOptions};
use super::threads::single_threaded_context;
use super::{Affine, Arc, BezPath, Document, FillRule, HashMap, Ink, Node, Rect, RenderContext, StrokeLayer, SymbolBoxes, TextObject, kurbo, live};
use super::{
Affine, Arc, BezPath, Document, FillRule, HashMap, Ink, Node, Pixmap, Rect, RenderContext, StrokeLayer, SymbolBoxes, TextObject, TypeAntiAlias,
fx, kurbo, live,
};
/// Hasher for pointer-keyed caches: a multiply-xorshift (SipHash showed up in frame profiles).
#[derive(Default, Clone, Copy)]
@@ -116,6 +119,26 @@ pub(crate) struct PlacedEntry {
pub(crate) slice: Option<vectorcraft_doc::NineSliceMap>,
}
/// Type drawn with an anti-aliasing method other than Sharp, as [`Renderer::draw_type_edges`] drew
/// it offscreen on the pixels of the output ([`Frame::output`]).
pub(crate) struct TypeEdgeEntry {
/// A copy of the type's object: the kept pixels are used only for an object equal to it.
pub(crate) node: Node,
/// The output's zoom and rotation ([`fx::linear_key`]).
pub(crate) linear: [u64; 4],
/// The method, the edge threshold, the frame's edge smoothing, whether the frame composites
/// precisely, and the opacity folded into the paints.
pub(crate) settings: (TypeAntiAlias, Option<u8>, AntiAlias, bool, u32),
/// What the type read from the document and the color settings ([`Renderer::raster_inputs`]).
pub(crate) inputs: fx::Inputs,
/// The output's offset: in a frame whose offset differs from it by whole pixels, the pixels are
/// placed moved by them.
pub(crate) shift: kurbo::Vec2,
/// The output pixel at the image's top-left corner.
pub(crate) origin: (f64, f64),
pub(crate) image: Arc<Pixmap>,
}
/// How deep inline graphics nest (text in a symbol shown inline in text…) before they draw nothing.
pub(crate) const MAX_INLINE_DEPTH: u32 = 4;
@@ -147,6 +170,10 @@ pub struct FrameStats {
/// How many threads composed the frame from its two ink planes, in a CMYK document that shows
/// transparency; 0 for other frames.
pub ink_threads: usize,
/// The type objects the frame drew offscreen with an anti-aliasing method other than Sharp
/// ([`Renderer::draw_type_edges`]). Type drawn from the pixels an earlier frame kept is not
/// counted.
pub type_edges: usize,
pub micros: u64,
}
@@ -160,11 +187,16 @@ pub(crate) struct Frame<'a> {
pub(crate) view: Affine,
/// Visible region in document coordinates (for culling).
pub(crate) visible: Rect,
/// Size of one output pixel in document units.
/// Size of one of this frame's pixels in document units.
pub(crate) px: f64,
pub(crate) opts: &'a RenderOptions,
/// What colours paint as: screen colours, or one ink plane of a CMYK document (see [`ink`]).
pub(crate) ink: Ink,
/// Document → pixels of the output being drawn: `view` itself, except while an export draws
/// its art at several times its size (the export's own pixels) and in offscreen layers (the
/// pixels they are placed back on). Type with an anti-aliasing method other than Sharp is
/// drawn on these pixels ([`Renderer::draw_type_edges`]).
pub(crate) output: Affine,
/// With Separations Preview, the tables placed images show the visible plates through.
pub(crate) plates: Option<&'a crate::proof::PlateLut>,
}
+3 -3
View File
@@ -73,13 +73,13 @@ pub(crate) use bounds::visual_bounds;
pub(crate) use bounds::{cull_bounds, drawn_outline, drawn_outlines, painted_reach, path_cull_bounds};
#[cfg(test)]
pub(crate) use entries::Drawn;
pub(crate) use entries::{DocInputs, FxGeom, Reads, ShadowEntry};
pub(crate) use entries::{DocInputs, FxGeom, Inputs, Reads, ShadowEntry};
pub(crate) use predicates::{has_fx, has_object_fx};
pub(crate) use raster::PixelEntry;
#[cfg(test)]
pub(crate) use raster::RASTERIZED;
#[cfg(test)]
use raster::{DRAG_PIXEL_BUDGET, PIXEL_BUDGET, drag_scale, filter_region, filter_region_within, pan_margin};
use raster::{DRAG_PIXEL_BUDGET, drag_scale, filter_region, filter_region_within, pan_margin};
pub(crate) use raster::{PIXEL_BUDGET, PixelEntry, linear_key};
#[cfg(test)]
mod tests;
+2 -1
View File
@@ -534,7 +534,8 @@ impl Renderer {
(true, false) => crate::AntiAlias::Art,
};
let opts = crate::RenderOptions { anti_alias, ..f.opts.clone() };
let frame = Frame { mt: false, view, visible: rect, px: cell, opts: &opts, ..*f };
// The image's pixels are the output's.
let frame = Frame { mt: false, view, visible: rect, px: cell, opts: &opts, output: view, ..*f };
let mut off = frame.offscreen_context(w, h);
self.inside_layer(|s| paint(s, &mut off, &frame));
off.flush();
+2 -1
View File
@@ -77,5 +77,6 @@ pub(super) fn rasterize_bounds(symbols: &SymbolBoxes, n: &Node, rfx: &[RasterFx]
/// `f` with the content moved by (`dx`, `dy`) document units (a shadow's offset).
pub(super) fn shifted<'a>(f: &Frame<'a>, dx: f64, dy: f64) -> Frame<'a> {
Frame { view: f.view * Affine::translate((dx, dy)), visible: f.visible - Vec2::new(dx, dy), ..*f }
let moved = Affine::translate((dx, dy));
Frame { view: f.view * moved, visible: f.visible - Vec2::new(dx, dy), output: f.output * moved, ..*f }
}
+2 -2
View File
@@ -29,7 +29,7 @@ pub(super) fn pcolor(ink: Ink, c: &vectorcraft_doc::color::Color) -> peniko::Col
/// Most pixels the raster of [`Renderer::pixel_content`] holds (a 4K screen's): larger content is
/// cropped to the view when its effects only look nearby, else rendered at a lower resolution.
pub(super) const PIXEL_BUDGET: f64 = 8.4e6;
pub(crate) const PIXEL_BUDGET: f64 = 8.4e6;
/// Most Rasterize effects of one stack that [`Renderer::raster_fx`] draws (the last ones): each
/// draws the content again inside the one after it.
@@ -121,7 +121,7 @@ impl PixelEntry {
}
/// The four linear coefficients of `view` (zoom and rotation, not the pan), as cache keys.
pub(super) fn linear_key(view: Affine) -> [u64; 4] {
pub(crate) fn linear_key(view: Affine) -> [u64; 4] {
let c = view.as_coeffs();
[c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()]
}
+1
View File
@@ -838,6 +838,7 @@ mod tests {
px: 1.0,
opts: &opts,
ink: Ink::Display,
output: Affine::IDENTITY,
plates: None,
};
let mut ctx = frame.offscreen_context(100, 100);

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