Merge remote-tracking branch 'upstream/main' into fix/envelope-edit-contents

# Conflicts:
#	docs/file-format-parity.md
This commit is contained in:
jmverges
2026-11-11 15:19:51 +00:00
129 changed files with 3527 additions and 350 deletions
Generated
+1
View File
@@ -6687,6 +6687,7 @@ dependencies = [
"vectorcraft-geom",
"wasmi",
"wat",
"web-time",
]
[[package]]
+7 -3
View File
@@ -870,10 +870,10 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio
on_type_path(n, t, p, opt.tol).then_some(HitKind::Outline)
}
// An image is hit within its frame (on the frame's edge only with Selection by Path Only,
// #3291), and where its own fills and strokes paint ([`image_paint`]) except in outline
// mode.
// #3291, and in outline mode where the frame is all that is drawn, #6437), and where its
// own fills and strokes paint ([`image_paint`]) except in outline mode.
NodeKind::Image(im) => {
if opt.path_only {
if opt.path_only || (opt.outline && !symbols.document().setup.outline_images) {
use vectorcraft_geom::kurbo::Shape;
let mut frame = Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)).to_path(0.1);
frame.apply_affine(im.xf);
@@ -909,6 +909,10 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio
if stroke_contains(&bp, 0.0, opt.tol, p) {
return Some(HitKind::Outline);
}
// Outline mode draws the mesh lines, not the colours between them (#6437).
if opt.outline {
return stroke_contains(&m.lines().to_bezpath(), 0.0, opt.tol, p).then_some(HitKind::Outline);
}
(!opt.path_only && fill_contains(&bp, vectorcraft_geom::FillRule::NonZero, p)).then_some(HitKind::Fill)
}
_ => None,
+39
View File
@@ -880,6 +880,45 @@ fn path_only_picks_paths_through_images() {
assert_eq!(hit(image_edge, path_only), Some(image), "path only: the image's frame");
}
/// #6437: in outline mode an image is drawn as its frame and a gradient mesh as its mesh lines,
/// so a click inside them, on the outline of a path behind, picks that path, and a click on the
/// frame or a mesh line picks the image or the mesh. In Preview they cover the path as before,
/// and an image whose pixels show in outline mode (Show Images in Outline Mode) still does.
#[test]
fn outline_mode_picks_paths_through_images_and_meshes() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let (path, image, under, mesh) = (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id());
d.insert(Some(l), 0, Node::path(path, shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::default_art())).unwrap();
let xf = vectorcraft_geom::Affine::translate((10.0, 10.0));
let im = crate::ImageObject { key: "k".into(), width: 80, height: 80, xf, link: None, placement: Default::default(), pixels: None };
d.insert(Some(l), 1, Node::new(image, NodeKind::Image(Box::new(im)))).unwrap();
d.insert(Some(l), 2, Node::path(under, shapes::rectangle(Rect::new(120.0, 120.0, 160.0, 160.0)), Appearance::default_art())).unwrap();
let area = shapes::rectangle(Rect::new(110.0, 110.0, 190.0, 190.0));
let m = crate::GradientMesh::for_path_with(&area, 2, 2, &|_| (Color::BLACK, 1.0)).unwrap();
d.insert(Some(l), 3, Node::new(mesh, NodeKind::Mesh(m))).unwrap();
let outline = HitOptions { outline: true, ..Default::default() };
let hit = |d: &Document, p: Point, opt| hit_test(d, p, opt).map(|h| h.leaf);
// The image: the path's edge and the inside, then the frame.
let (path_edge, inside, frame) = (Point::new(20.5, 40.0), Point::new(80.0, 80.0), Point::new(89.5, 50.0));
assert_eq!(hit(&d, path_edge, HitOptions::default()), Some(image), "Preview: the image covers the path");
assert_eq!(hit(&d, inside, HitOptions::default()), Some(image));
assert_eq!(hit(&d, path_edge, outline), Some(path), "outline: the path through the image");
assert_eq!(hit(&d, inside, outline), None, "outline: not inside the image");
assert_eq!(hit(&d, frame, outline), Some(image), "outline: the image's frame");
// The mesh: the path's edge and the inside, then its edge and a mesh line inside it.
let (under_edge, inside, edge, line) = (Point::new(120.5, 130.0), Point::new(130.0, 170.0), Point::new(189.5, 130.0), Point::new(150.0, 170.0));
assert_eq!(hit(&d, under_edge, HitOptions::default()), Some(mesh), "Preview: the mesh covers the path");
assert_eq!(hit(&d, inside, HitOptions::default()), Some(mesh));
assert_eq!(hit(&d, under_edge, outline), Some(under), "outline: the path through the mesh");
assert_eq!(hit(&d, inside, outline), None, "outline: not between the mesh lines");
assert_eq!(hit(&d, edge, outline), Some(mesh), "outline: the mesh's edge");
assert_eq!(hit(&d, line, outline), Some(mesh), "outline: a mesh line");
// Show Images in Outline Mode: the image's pixels are drawn, and it covers the path again.
d.setup.outline_images = true;
assert_eq!(hit(&d, path_edge, outline), Some(image));
}
/// Type Object Selection by Path Only: point type is picked on its baseline, area type on its
/// frame and type on a path on its path, no longer anywhere in its bounds.
#[test]
+23 -7
View File
@@ -25,17 +25,33 @@ const MAX_OVERLAP_TESTS: usize = 1 << 22;
/// The width of the stroke around each face that covers the seams between faces.
const SEAM_WIDTH: f64 = 0.75;
/// About the bytes one copy of `paint` takes.
/// About the bytes one copy of `paint` takes, with the strings it owns (swatch and pattern names).
fn paint_bytes(paint: &Paint) -> usize {
use std::mem::size_of;
use vectorcraft_doc::color::{FreeformPoint, GradientStop};
let own = size_of::<Paint>();
let Paint::Gradient(g) = paint else { return own };
let freeform = g
.freeform
.as_ref()
.map_or(0, |f| f.points.len() * size_of::<FreeformPoint>() + f.lines.iter().map(|l| l.len() * size_of::<usize>()).sum::<usize>());
own + size_of::<vectorcraft_doc::color::GradientPaint>() + g.gradient.stops.len() * size_of::<GradientStop>() + freeform
let name = |s: &Option<String>| s.as_ref().map_or(0, String::len);
let g = match paint {
Paint::Solid { swatch, .. } => return own + name(swatch),
Paint::Pattern { pattern, .. } => return own + pattern.len(),
Paint::Gradient(g) => g,
_ => return own,
};
let freeform = g.freeform.as_ref().map_or(0, |f| {
// Each line is a Vec: its header too, not only its indices.
f.points.len() * size_of::<FreeformPoint>()
+ f.lines.len() * size_of::<Vec<usize>>()
+ f.lines.iter().map(|l| l.len() * size_of::<usize>()).sum::<usize>()
});
let stops: usize = g.gradient.stops.iter().map(|s| size_of::<GradientStop>() + name(&s.swatch)).sum();
own + size_of::<vectorcraft_doc::color::GradientPaint>() + name(&g.swatch) + stops + freeform
}
/// Add `copies` copies of `paint` to the running byte total `used`; whether it still fits within
/// [`MAX_PAINT_COPIES`] (for the solid surfaces, whose items' copies count together).
pub(crate) fn paint_copies_add(used: &mut usize, copies: usize, paint: &Paint) -> bool {
*used = used.saturating_add(copies.saturating_mul(paint_bytes(paint)));
*used <= MAX_PAINT_COPIES
}
fn visible(fx: &[Effect]) -> bool {
+21
View File
@@ -484,6 +484,21 @@ fn paint_of(item: &AppearanceItem, member: f32) -> (Paint, f32, BlendMode) {
}
}
/// `item`'s paint, borrowed (for checks that must not copy it).
fn paint_ref(item: &AppearanceItem) -> &Paint {
match item {
AppearanceItem::Fill(f) => &f.paint,
AppearanceItem::Stroke(s) => &s.paint,
}
}
/// Paint copies `surface` makes for `facets`: two per facet (fill and seam stroke), six when colored
/// lights give each facet derived paints (`lighting::solid_surface`).
fn facet_copies(facets: &[Facet], paint: &Paint, colored: bool) -> usize {
let derived = colored && paint.color().is_some() && facets.iter().any(|f| f.rgb_gradient.is_some());
facets.len().saturating_mul(if derived { 6 } else { 2 })
}
/// The area `item` paints on `path`, and the rule that fills it: the path itself for a fill, the
/// stroke's outline (whose contours nest as its holes do) for a stroke.
fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData, FillRule) {
@@ -543,6 +558,8 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>>
let classic = fx.id == CLASSIC_EXTRUDE;
let colored = !classic && view_options(&p).has_colored_lights();
let masks = has_masks(shapes);
// The paint copies of every solid together: past the allowance the source path is drawn.
let mut paint_bytes = 0usize;
if fx.id == ROTATE {
// Every part turns about the centre of the object's own bounds (a group's members about
// the centre of theirs), so they stay together.
@@ -552,6 +569,9 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>>
let (paint, opacity, blend) = paint_of(item, s.opacity);
let (area, rule) = region(&s.path, s.rule, item);
let f = facets(ROTATE, &area, rule, b, &p).ok()?;
if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, &paint, colored), &paint) {
return None;
}
out.push((s.scopes.as_slice(), Arc::new(surface(n, f, &paint, opacity, blend, colored))));
}
}
@@ -569,6 +589,7 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>>
for ((item, opacity, _, _, scopes), f) in parts.iter().zip(built) {
let item: &AppearanceItem = item;
match f {
Ok(f) if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, paint_ref(item), colored), paint_ref(item)) => return None,
Ok(f) => solids.push((item, *opacity, *scopes, f)),
// A stroke's outline past the limits is left out; the fill's solid still draws.
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {}
+20 -3
View File
@@ -14,6 +14,10 @@ use super::{ARCLEN_ACCURACY, kink, push_seg, segments, subpaths, tangent};
/// More dashes than this (a tiny pattern on a long path) draws the line solid instead.
const MAX_DASHES: f64 = 200_000.0;
/// Pattern entries walked in all (every subpath and every corner restart starts the pattern over, and
/// zero-length entries cost no path length, so a long pattern is walked in full even on a short
/// run); past this the line is drawn solid. About 100x what real art needs.
const MAX_STEPS: f64 = 20_000_000.0;
/// A pattern whose longest entry is shorter than this can't advance along the path (each entry
/// ends within the generator's tolerance of where it starts), so it draws the line solid.
const MIN_ENTRY: f64 = 1e-6;
@@ -80,6 +84,10 @@ pub fn dash_kept(bp: &BezPath, d: &Dash, keep: &dyn Fn(&PathSeg) -> bool) -> Opt
if plans.iter().map(|p| p.periods).sum::<f64>() * pat.len() as f64 > MAX_DASHES {
return None;
}
let restarts = |p: &Plan| p.restart.iter().filter(|r| **r).count() as f64;
if plans.iter().map(|p| (p.periods.ceil().max(1.0) + restarts(p)) * pat.len() as f64).sum::<f64>() > MAX_STEPS {
return None;
}
// Where an exact pattern starts: `offset` into it.
let mut off = d.offset.rem_euclid(period);
let mut ix0 = 0;
@@ -89,8 +97,13 @@ pub fn dash_kept(bp: &BezPath, d: &Dash, keep: &dyn Fn(&PathSeg) -> bool) -> Opt
}
let phase = Phase { ix: ix0, rem: pat[ix0] - off, on: ix0 % 2 == 0 };
let mut out = Dashed::default();
// The estimates above trust the fitted scales; this counts the pattern entries actually walked,
// so entries that fit down to nothing can't keep a subpath looping.
let mut budget = MAX_STEPS as usize;
for plan in &plans {
dash_subpath(&mut out, plan, &pat, phase, keep);
if !dash_subpath(&mut out, plan, &pat, phase, keep, &mut budget) {
return None;
}
}
Some(out)
}
@@ -183,7 +196,8 @@ impl Piece {
}
}
fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep: &dyn Fn(&PathSeg) -> bool) {
/// Dash one subpath; `false` when `budget` (pattern entries left to walk, shared by all subpaths) runs out.
fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep: &dyn Fn(&PathSeg) -> bool, budget: &mut usize) -> bool {
let first_dot = out.dots.len();
let total: f64 = plan.lens.iter().sum();
// Fraction of the subpath's length at `x` along the plan.
@@ -244,6 +258,8 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep:
push_dot(out, first_dot, Dot { at: seg.eval(t), dir: tangent(seg, t), t: frac(acc + end) });
}
}
let Some(rest) = budget.checked_sub(1) else { return false };
*budget = rest;
cuts += 1;
s = end;
ph.ix = (ph.ix + 1) % pat.len();
@@ -273,7 +289,7 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep:
write(&mut out.path, &d.segs);
out.path.close_path();
out.spans.push((frac(0.0), frac(0.0) + 1.0));
return;
return true;
}
if starts_on && ends_on && dashes.len() > 1 {
// The dash running through the start point is one dash.
@@ -291,6 +307,7 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep:
let t0 = frac(d.start);
out.spans.push((t0, if total > 0.0 { t0 + (d.end - d.start) / total } else { t0 }));
}
true
}
/// Add `dot` unless the subpath's last dot (from index `first`) is already there: a run ending on
+14
View File
@@ -222,3 +222,17 @@ fn smooth_points_of_curves_stay_smooth_with_any_join() {
assert!((a - want).abs() / want < 0.005, "{join:?}: {a} vs {want}");
}
}
#[test]
fn a_long_pattern_restarted_on_many_short_subpaths_draws_solid() {
// Each subpath walks the zero-length entries in full although it is far shorter than a period.
let mut pattern = vec![0.0; 2000];
pattern[0] = 1.0;
let mut b = BezPath::new();
for i in 0..20_000 {
b.move_to((i as f64 * 2.0, 0.0));
b.line_to((i as f64 * 2.0 + 0.01, 0.0));
}
let d = Dash { pattern, offset: 0.0, align_corners: false };
assert!(dash(&b, &d).is_none());
}
+3 -2
View File
@@ -1101,8 +1101,9 @@ pub fn with_id(xml: &str, id: &str) -> Option<String> {
let f = filter_element(&doc).ok()?;
let at = f.attributes().find(|a| a.name() == "id" && a.namespace().is_none())?.range_value();
let (start, end) = (at.start.checked_sub(head)?, at.end.checked_sub(head)?);
// The value may sit in single quotes: those are escaped too.
let id = escaped(id).replace('\'', "&apos;");
// The value may sit in single quotes: those are escaped too. A tab, line feed or carriage
// return is written as a character reference, which attribute-value normalization keeps.
let id = escaped(id).replace('\'', "&apos;").replace('\t', "&#9;").replace('\n', "&#10;").replace('\r', "&#13;");
Some(format!("{}{}{}", xml.get(..start)?, id, xml.get(end..)?))
}
+54 -9
View File
@@ -218,34 +218,66 @@ fn visible(sources: &[TrapSource]) -> Vec<PathData> {
/// The pairs `(i, j)`, `i < j`, in ascending order, of `rects` (`None`: left out) that come within
/// `reach_x` of each other across x and `reach_y` across y. A sweep over the rects sorted by their
/// left edge, so shapes spread over the page cost about one check each rather than one per pair.
fn near_pairs(rects: &[Option<Rect>], reach_x: f64, reach_y: f64) -> Vec<(usize, usize)> {
/// `None` when there are more than [`MAX_NEAR_PAIRS`] (many shapes all within reach of each other
/// make the pair list grow with the square of their count) or the sweep examines more than
/// `MAX_NEAR_SCAN` pairs (many shapes sharing an x range, far apart in y).
fn near_pairs(rects: &[Option<Rect>], reach_x: f64, reach_y: f64) -> Option<Vec<(usize, usize)>> {
near_pairs_within(rects, reach_x, reach_y, MAX_NEAR_SCAN)
}
fn near_pairs_within(rects: &[Option<Rect>], reach_x: f64, reach_y: f64, max_scan: usize) -> Option<Vec<(usize, usize)>> {
let mut order: Vec<(usize, Rect)> = rects.iter().enumerate().filter_map(|(i, r)| r.map(|r| (i, r))).collect();
order.sort_unstable_by(|a, b| a.1.x0.total_cmp(&b.1.x0).then(a.0.cmp(&b.0)));
let mut pairs = vec![];
let mut scanned = 0usize;
for (k, &(i, a)) in order.iter().enumerate() {
// Later rects start at or right of `a`, so only how far right they start can rule them out.
for &(j, b) in order[k + 1..].iter().take_while(|(_, b)| b.x0 <= a.x1 + reach_x) {
// Rects sharing an x range but far apart in y are examined without being stored.
scanned += 1;
if scanned > max_scan {
return None;
}
if b.y0 <= a.y1 + reach_y && a.y0 <= b.y1 + reach_y {
if pairs.len() >= MAX_NEAR_PAIRS {
return None;
}
pairs.push((i.min(j), i.max(j)));
}
}
}
pairs.sort_unstable();
pairs
Some(pairs)
}
/// Most pairs the sweep in `near_pairs` examines, stored or not (a plain bounds check each).
const MAX_NEAR_SCAN: usize = 1 << 27;
/// Most candidate pairs one trap evaluation keeps; past it no traps are made (the art draws as is).
/// Each pair costs a boolean trap band, so real art stays far below this.
pub const MAX_NEAR_PAIRS: usize = 1 << 20;
/// The traps between `sources` (back to front) with options `o`: for each two sources of
/// different solid colours whose visible parts meet, the strip of the darker one's visible part
/// next to the lighter one's (the other way round with [`TrapOptions::reverse`]), painted per
/// [`trap_paint`]. Empty when no visible edges of different colours meet.
/// [`trap_paint`]. Empty when no visible edges of different colours meet, or when there are more
/// than [`MAX_NEAR_PAIRS`] candidate pairs or the pair scan is too long (see [`try_traps`] to tell
/// those apart).
pub fn traps(sources: &[TrapSource], o: &TrapOptions) -> Vec<TrapPiece> {
try_traps(sources, o).unwrap_or_default()
}
/// [`traps`], but `None` when there are more than [`MAX_NEAR_PAIRS`] candidate pairs or the scan for
/// them is too long (`MAX_NEAR_SCAN` pairs examined), as opposed to
/// `Some` of an empty list when no edges meet.
pub fn try_traps(sources: &[TrapSource], o: &TrapOptions) -> Option<Vec<TrapPiece>> {
let colors: Vec<Option<Color>> = sources.iter().map(|s| trap_color(&s.fill)).collect();
if colors.iter().flatten().count() < 2 {
return vec![];
return Some(vec![]);
}
let (w, r) = (o.thickness, o.height_width);
if !(w.is_finite() && w > 0.0 && r.is_finite() && r > 0.0) {
return vec![]; // `trap_band` cuts nothing with these.
return Some(vec![]); // `trap_band` cuts nothing with these.
}
let vis = visible(sources);
// Only pairs whose visible parts come within the trap's reach can trap (`trap_band` rejects
@@ -257,7 +289,8 @@ pub fn traps(sources: &[TrapSource], o: &TrapOptions) -> Vec<TrapPiece> {
let extent = rects.iter().flatten().flat_map(|b| [b.x0, b.y0, b.x1, b.y1]).fold(0.0_f64, |m, v| m.max(v.abs()));
let hair = 1e-9 * (1.0 + extent + w * r.max(1.0));
let mut out = vec![];
for (i, j) in near_pairs(&rects, w + hair, w * r + hair) {
let pairs = near_pairs(&rects, w + hair, w * r + hair)?;
for (i, j) in pairs {
let (Some(ci), Some(cj)) = (colors[i], colors[j]) else { continue };
if vis[i].is_empty() || vis[j].is_empty() || same_color(&sources[i].fill, &sources[j].fill, ci, cj) {
continue;
@@ -276,7 +309,7 @@ pub fn traps(sources: &[TrapSource], o: &TrapOptions) -> Vec<TrapPiece> {
let Some(color) = colors[from] else { continue };
out.push(TrapPiece { path: band, paint: trap_paint(&sources[from].fill, color, o), from, into });
}
out
Some(out)
}
/// A trap's appearance: one overprinting fill of `paint`, no stroke.
@@ -379,10 +412,19 @@ mod tests {
assert!(near(po::area(&t[0].path, FillRule::NonZero), 22.0 * 22.0 - 400.0));
}
#[test]
fn the_pair_scan_is_bounded_even_when_no_pair_is_near() {
// Eleven rects share an x range but lie far apart in y: 55 pairs are examined, none stored.
let rects: Vec<Option<Rect>> = (0..11).map(|k| Some(Rect::new(0.0, k as f64 * 1000.0, 10.0, k as f64 * 1000.0 + 10.0))).collect();
assert_eq!(near_pairs_within(&rects, 1.0, 1.0, 100), Some(vec![]));
assert_eq!(near_pairs_within(&rects, 1.0, 1.0, 54), None);
}
#[test]
fn no_trap_without_adjoining_edges_of_different_colours() {
let apart = vec![src(Rect::new(0.0, 0.0, 10.0, 10.0), yellow()), src(Rect::new(30.0, 0.0, 40.0, 10.0), blue())];
assert!(traps(&apart, &TrapOptions::default()).is_empty());
assert_eq!(try_traps(&apart, &TrapOptions::default()).map(|t| t.len()), Some(0), "no edges meet: empty, not over the cap");
let same = vec![src(Rect::new(0.0, 0.0, 10.0, 10.0), blue()), src(Rect::new(10.0, 0.0, 20.0, 10.0), blue())];
assert!(traps(&same, &TrapOptions::default()).is_empty());
let mut unfilled = abutting();
@@ -431,10 +473,13 @@ mod tests {
fn only_shapes_within_reach_are_paired() {
let s = many_apart_and_one_abutting();
let rects: Vec<Option<Rect>> = s.iter().map(|t| t.path.bounds()).collect();
assert_eq!(near_pairs(&rects, 1.0, 1.0), vec![(0, 2000)], "the squares 10 pt apart are never paired");
assert_eq!(near_pairs(&rects, 1.0, 1.0), Some(vec![(0, 2000)]), "the squares 10 pt apart are never paired");
// Reaching 10 pt, neighbours in a row pair, in ascending order; left-out rects never do.
let row: Vec<Option<Rect>> = [3, 1, 0, 2].iter().map(|&k| s[k].path.bounds()).chain([None]).collect();
assert_eq!(near_pairs(&row, 10.0, 10.0), vec![(0, 3), (1, 2), (1, 3)]);
assert_eq!(near_pairs(&row, 10.0, 10.0), Some(vec![(0, 3), (1, 2), (1, 3)]));
// Past the allowance the pair list stops growing instead of being built.
let packed = vec![Some(Rect::new(0.0, 0.0, 1.0, 1.0)); 1500];
assert_eq!(near_pairs(&packed, 1.0, 1.0), None);
let t = traps(&s, &TrapOptions { thickness: 1.0, ..Default::default() });
assert_eq!(t.len(), 1, "only the abutting pair traps");
assert_eq!((t[0].from, t[0].into), (2000, 0));
+4 -3
View File
@@ -774,8 +774,9 @@ fn divide_dashes(s: &mut Session, p: &Value) -> Result<Value> {
continue;
}
let only = (segs.len() < sp.segment_count()).then_some(&segs);
let out = pt::divide_dashes(sp, only, spec, MAX_PIECES.saturating_sub(made))
.ok_or_else(|| bad(C, "that makes too many pieces (at most 100000): make the dashes longer"))?;
let out = pt::divide_dashes(sp, only, spec, MAX_PIECES.saturating_sub(made)).ok_or_else(|| {
bad(C, "that makes too many pieces (at most 100000) or dash steps: simplify the dash pattern or make the dashes longer")
})?;
made += out.len();
for (piece, kind) in out.into_iter().filter(|(_, k)| !(remove_gaps && *k == pt::Piece::Gap)) {
dashes += usize::from(kind == pt::Piece::Dash);
@@ -825,7 +826,7 @@ fn dashed_paths(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
fn expand_dashes(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.expandDashes";
let too_many = || bad(C, "that makes too many pieces (at most 100000): make the dashes longer");
let too_many = || bad(C, "that makes too many pieces (at most 100000) or dash steps: simplify the dash pattern or make the dashes longer");
let roots = checked_ids_or_selection(s, p, C)?;
let d = &s.doc()?.doc;
// Each dashed path and its dashes, as the canvas draws them.
+1 -1
View File
@@ -496,7 +496,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
// nothing to apply is no edit, and a click that only applies brushes the document has is
// named for that.
let mut probe = (*st.doc).clone();
let imported = clip.import_into(&mut probe, &choices, false);
let imported = clip.import_into(&mut probe, &choices, false)?;
let doc_names: Vec<String> = picked.iter().map(|n| target(&imported.renamed, n)).collect();
let paths = if apply { brush_targets(&st.doc, &roots(s, p)?) } else { vec![] };
let paint = stroke_paint(s);
+66 -15
View File
@@ -28,7 +28,7 @@ use vectorcraft_doc::{
use super::super::brushsym::{set_natural_size, stored_natural_size};
use super::super::patterncmds::add_pattern;
use super::super::{Result, bad, unique_name};
use super::super::{EngineError, Result, bad, unique_name};
use super::Clipboard;
/// A resource kind objects refer to (graphic styles by id, the others by name).
@@ -165,6 +165,23 @@ fn node_filters(n: &Node, out: &mut BTreeSet<(String, String)>) {
});
}
/// The names the SVG filter effects of appearance `a` give (its own and its fills' and strokes').
fn appearance_filter_names(a: &Appearance, out: &mut BTreeSet<String>) {
let items = a.items.iter().flat_map(|i| i.effects().iter());
let named = a.effects.iter().chain(items).filter(|e| e.id == Appearance::SVG_FILTER);
out.extend(named.filter_map(|e| e.params.get("name").and_then(Value::as_str)).map(str::to_string));
}
/// The names the SVG filter effects in the subtree of `n` give (opacity-mask art included).
fn node_filter_names(n: &Node, out: &mut BTreeSet<String>) {
n.walk(&mut |m| {
appearance_filter_names(&m.appearance, out);
if let Some(mask) = &m.extras().mask {
node_filter_names(&mask.art, out);
}
});
}
/// A global or spot colour of its own (not a tint of another swatch): what colours link to.
fn is_base(w: &Swatch) -> bool {
(w.global || w.spot) && matches!(w.paint, Paint::Solid { swatch: None, .. })
@@ -1038,8 +1055,9 @@ impl Clipboard {
}
/// Add the resources the objects use to `d` (inside the paste's undo step) and work out how
/// pasted copies relink to them.
pub(crate) fn import_into(&self, d: &mut Document, choices: &SwatchChoices, same_doc: bool) -> Imported {
/// pasted copies relink to them. An error, with `d` unchanged, when a pasted SVG filter finds no
/// free name ([`Self::filter_links`]).
pub(crate) fn import_into(&self, d: &mut Document, choices: &SwatchChoices, same_doc: bool) -> Result<Imported> {
self.import_used(d, &self.used(), choices, same_doc, false)
}
@@ -1048,9 +1066,9 @@ impl Clipboard {
/// each style, symbol, pattern and brush comes after the styles, symbols, patterns and brushes it uses
/// ([`use_order`]), so that it is compared and relinked with their names here, and each loop
/// of definitions that use each other is matched on its own ([`loop_groups`]).
fn import_used(&self, d: &mut Document, used: &Used, choices: &SwatchChoices, same_doc: bool, in_use_order: bool) -> Imported {
fn import_used(&self, d: &mut Document, used: &Used, choices: &SwatchChoices, same_doc: bool, in_use_order: bool) -> Result<Imported> {
let mut out = Imported::default();
out.relink.filters = self.filter_links(d, same_doc);
out.relink.filters = self.filter_links(d, same_doc)?;
self.import_images(d, used, same_doc, &mut out);
self.import_swatches(d, used, choices, same_doc, &mut out);
// Brushes whose art shows no symbols or patterns, before the symbols and patterns whose art
@@ -1213,14 +1231,16 @@ impl Clipboard {
&& out.relink.styles.is_empty()
&& out.relink.colors.is_empty()
&& used.styles.is_empty());
out
Ok(out)
}
/// How the pasted SVG filter effects relink ([`Relink::filters`]). Each names the document's
/// filter of its name; one that draws another definition than that filter takes the document's
/// filter when pasted back into the document it came from, and elsewhere keeps its own under a
/// free name, so editing or deleting the document's filter leaves it alone.
fn filter_links(&self, d: &Document, same_doc: bool) -> BTreeMap<(String, String), (String, String)> {
/// name no filter or effect of the document gives, so editing or deleting the document's filter leaves it alone. When that name would
/// make the filter too long to draw, the free name is a shorter start of its name ("Shif"), else
/// a free letter or digit; the filter's id is always its name. An error when no free name fits.
fn filter_links(&self, d: &Document, same_doc: bool) -> Result<BTreeMap<(String, String), (String, String)>> {
let mut found = BTreeSet::new();
for n in &self.nodes {
node_filters(n, &mut found);
@@ -1238,20 +1258,51 @@ impl Clipboard {
appearance_filters(&g.appearance, &mut found);
}
let mut taken: BTreeSet<String> = d.svg_filters.iter().map(|f| f.name.clone()).chain(found.iter().map(|(n, _)| n.clone())).collect();
let mut effects_read = false;
let mut links = BTreeMap::new();
for (name, xml) in found {
let Some(held) = d.svg_filters.iter().find(|f| f.name == name).filter(|f| f.xml != xml.trim()) else { continue };
let to = if same_doc {
(name.clone(), held.xml.clone())
} else {
let new = unique_name(&name, |n| taken.contains(n));
// The names the document's effects give are taken too, those whose filter was
// deleted included: defining a filter of that name later changes every effect that
// names it. They are read at the first rename only.
if !effects_read {
effects_read = true;
for root in d.art_roots() {
node_filter_names(root, &mut taken);
}
for a in vectorcraft_brush::library(d).iter().flat_map(Brush::art) {
node_filter_names(a, &mut taken);
}
for g in &d.graphic_styles {
appearance_filter_names(&g.appearance, &mut taken);
}
}
// Free names made from the starts of the name, longest first, then free letters and
// digits: the first under which the filter still draws.
let mut ends: Vec<usize> = name.char_indices().map(|(i, _)| i).skip(1).collect();
ends.push(name.len());
let starts = ends.iter().rev().filter_map(|&end| name.get(..end).map(str::trim_end)).filter(|b| !b.is_empty());
let shorter = starts.map(|b| unique_name(b, |n| taken.contains(n)));
let single = ('a'..='z').chain('0'..='9').map(String::from).filter(|n| !taken.contains(n));
let renamed = shorter.chain(single).find_map(|new| {
let drawn = vectorcraft_render::effects::svg_filter::with_id(&xml, &new)
.filter(|x| vectorcraft_render::effects::svg_filter::check(x).is_ok_and(|id| id == new))?;
Some((new, drawn))
});
let Some((new, drawn)) = renamed else {
return Err(EngineError::Other(format!(
"the pasted SVG filter {name:?} draws differently from the document's filter of that name, and no free name fits it"
)));
};
taken.insert(new.clone());
let drawn = vectorcraft_render::effects::svg_filter::with_id(&xml, &new).unwrap_or_else(|| xml.clone());
(new, drawn)
};
links.insert((name, xml), to);
}
links
Ok(links)
}
/// Image blobs: a different blob under a key the document uses gets a new key.
@@ -1366,13 +1417,13 @@ pub(crate) struct LoadedStyle {
/// a pattern added under a free name and a pattern follows a style renamed on the way in. A style
/// `d` has under its name, or a numbered copy of it ("Body 2"), that is the same once relinked is
/// used; any other is added under a free name. → each style of `src` placed, in order, character
/// styles first.
pub(crate) fn import_text_styles(src: &Document, d: &mut Document, chars: bool, paras: bool) -> Vec<LoadedStyle> {
/// styles first. An error, as [`Clipboard::import_into`] gives.
pub(crate) fn import_text_styles(src: &Document, d: &mut Document, chars: bool, paras: bool) -> Result<Vec<LoadedStyle>> {
let library = OnceCell::new();
let used = Used::of_text_styles(&Defs::of(src, &library), chars, paras);
let clip = Clipboard::with_used(src, &used, &library, vec![]);
let before = [&d.char_styles, &d.para_styles].map(|l| l.iter().map(|s| s.name.clone()).collect::<BTreeSet<_>>());
let out = clip.import_used(d, &used, &SwatchChoices::default(), false, true);
let out = clip.import_used(d, &used, &SwatchChoices::default(), false, true)?;
let mut placed: Vec<LoadedStyle> = vec![];
for (para, r, list) in [(false, Res::CharStyle, &clip.char_styles), (true, Res::ParaStyle, &clip.para_styles)] {
for s in list {
@@ -1382,7 +1433,7 @@ pub(crate) fn import_text_styles(src: &Document, d: &mut Document, chars: bool,
placed.push(LoadedStyle { para, name, added });
}
}
placed
Ok(placed)
}
/// Swatch `w` in `d` ([`existing`]), else added under a free name (swatches and colour groups
@@ -478,6 +478,32 @@ fn an_opened_file_measures_its_type_in_its_own_composite_fonts() {
assert_eq!(actual.cached_bounds, Some(expected));
}
/// Threaded type read from a file flows through its frames in the file's own composite fonts, when
/// no open document defines them.
#[test]
fn an_opened_file_flows_its_threads_in_its_own_composite_fonts() {
let mut s = session();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Threaded")]})).unwrap();
let story = "The quick brown fox jumps over the lazy dog and keeps running. ".repeat(4);
let a = s
.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "size": 20, "font": "CF Threaded", "area": {"width": 150, "height": 60}}))
.unwrap()["id"]
.as_u64()
.unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 300})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
let first = text(&s, a).plain_text();
assert!(!first.is_empty() && first.len() < story.len());
let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
// A new front document without the font: the font database no longer holds it.
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
assert!(vectorcraft_text::FontDb::global().composite_font("CF Threaded").is_none());
let file = crate::cmd::fileio::read_for_open("threaded.vectorcraft", &bytes, None, &json!({}), false, false).unwrap();
crate::cmd::fileio::open_read(&mut s, file).unwrap();
assert_eq!(text(&s, a).plain_text(), first);
}
/// A document replaced while in the back (File › Revert) is measured in its own composite fonts
/// when it comes to the front, not in the front document's.
#[test]
+1 -1
View File
@@ -1332,7 +1332,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
vec![Affine::IDENTITY]
};
let (ids, imported, artboards, into) = s.edit(mode.label(), |d, sel| {
let imported = clip.import_into(d, choices, same_doc);
let imported = clip.import_into(d, choices, same_doc)?;
// Draw Inside: the clip group of the object drawn into, made on the first paste.
let into = inside.map(|t| super::create::draw_inside_group(d, t)).transpose()?;
let artboards: Vec<usize> = boards
+3 -1
View File
@@ -270,7 +270,9 @@ fn flattened(src: &Document, at: usize, id: NodeId) -> Result<Option<Flat>> {
let pt = im.xf.determinant().abs().sqrt();
let ppi = if pt.is_finite() && pt > 0.0 { (72.0 / pt).clamp(MIN_PPI, MAX_PPI) } else { MIN_PPI };
let area = (bounds.width() * bounds.height()).max(1.0);
let scale = (ppi / 72.0).min((super::super::menucmds::MAX_PIXELS / area).sqrt());
// No side longer than the renderer draws (rounded up to whole pixels below).
let longest = (f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 2.0) / bounds.width().max(bounds.height()).max(1.0);
let scale = (ppi / 72.0).min((super::super::menucmds::MAX_PIXELS / area).sqrt()).min(longest);
if !(scale.is_finite() && scale > 0.0) {
return Ok(None);
}
+5 -1
View File
@@ -506,7 +506,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
// document keeps its text as saved), and every frame of a thread takes its first frame's
// Optical Margin Alignment setting and writing direction. When that changes the direction of a
// frame of a restored document, the story flows through that frame's thread again. Threaded
// frames keep their size: Auto Size is off for them.
// frames keep their size: Auto Size is off for them. The story is laid out in the document's
// own composite fonts.
let fonts = if doc.text_threads.is_empty() { Default::default() } else { doc.composite_fonts.clone() };
let lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&fonts);
if !doc.text_threads.is_empty() && !restored {
crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc);
} else if !doc.text_threads.is_empty() {
@@ -517,6 +520,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
// paragraph's first-line indent and list marker.
crate::cmd::threads::mark_continued(&mut doc);
}
drop(lent);
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays);
}
+4 -2
View File
@@ -112,10 +112,12 @@ impl Gen<'_> {
let start = if middle { Point::new(at.x, at.y - half) } else { at };
let mut t = self.text_object(role, start, &s.replace('|', "\n"), justify);
// Kept leading or paragraph spacing moves the lines: put the middle of the first and last baselines on `at`. An
// empty first or last line has no baseline, and such a label stays where the default leading puts it.
// empty line has no baseline: an empty first or last line leaves the label where the default leading puts it.
if middle && kept_para(self.styles, role).is_some() {
let ys: Vec<f64> = t.baselines().map(|(a, _)| a.y).collect();
if let (Some(first), Some(last), true) = (ys.first(), ys.last(), ys.len() == lines) {
let ends_set = s.split('|').next().is_some_and(|l| !l.is_empty()) && s.split('|').next_back().is_some_and(|l| !l.is_empty());
let set = s.split('|').filter(|l| !l.is_empty()).count();
if let (Some(first), Some(last), true) = (ys.first(), ys.last(), ends_set && ys.len() == set) {
t.xf = Affine::translate((0.0, half - (first + last) / 2.0)) * t.xf;
}
}
+27
View File
@@ -4370,6 +4370,33 @@ fn a_centered_label_with_kept_leading_stays_centered_when_the_graph_is_drawn_aga
assert!((after - before).abs() < 0.01, "the two-line category label keeps its middle: {before} {after}");
}
#[test]
fn a_centered_label_with_an_empty_middle_line_and_kept_leading_stays_centered() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let csv = ",2024,2025\nQ||1,3,2\nQ2,5,4\nQ3,-1,6";
let id = NodeId(
s.execute("graph.create", &json!({"type": "bar", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
.as_u64()
.unwrap(),
);
let middle = |s: &Session| {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let label =
group(&n, "Axes").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "Q\n\n1")).unwrap();
let b = label.geometric_bounds().unwrap();
(b.y0 + b.y1) / 2.0
};
let before = middle(&s);
let l = text_id(&s, id, "Axes", "Q\n\n1");
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
s.execute("text.setFormat", &json!({"autoLeading": 400})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
let after = middle(&s);
assert!((after - before).abs() < 0.01, "the three-line category label keeps its middle: {before} {after}");
}
#[test]
fn a_legend_across_top_measures_its_labels_with_system_layout() {
let mut s = Session::new();
+2 -2
View File
@@ -1303,7 +1303,7 @@ fn use_pattern(s: &mut Session, data: &Value, stroke: bool) -> Result<String> {
let choices = SwatchChoices::default();
// The pattern's name in the document, worked out on a copy: nothing to add is no edit.
let mut probe = (*s.doc()?.doc).clone();
let imported = clip.import_into(&mut probe, &choices, false);
let imported = clip.import_into(&mut probe, &choices, false)?;
let renamed = imported.renamed.iter().find(|r| r["kind"] == "pattern" && r["from"] == name.as_str()).and_then(|r| r["to"].as_str());
let target = renamed.unwrap_or(&name).to_string();
let paint = vectorcraft_doc::pattern::pattern_paint(&target);
@@ -1315,7 +1315,7 @@ fn use_pattern(s: &mut Session, data: &Value, stroke: bool) -> Result<String> {
in_one_step(s, |s| {
if added {
s.edit("Add to Swatches", |d, _| {
clip.import_into(d, &choices, false);
clip.import_into(d, &choices, false)?;
if !has_swatch(d) {
let swatch = d.free_swatch_name(&target);
d.swatches.push(vectorcraft_color::Swatch { name: swatch, paint: paint.clone(), global: false, spot: false });
+6 -2
View File
@@ -115,7 +115,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Rasterize…",
["Object"],
None,
"{ppi?, background?: transparent|white|black|\"#rrggbb\" (#rrggbbaa and #rgba are refused), antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded image (PNG; in a CMYK document with its own colour model, a CMYK image of the art's inks, its transparency as its opacity mask); each defaults to document.rasterEffectsSettings; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}",
"{ppi?, background?: transparent|white|black|\"#rrggbb\" (#rrggbbaa and #rgba are refused), antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded image (PNG; in a CMYK document with its own colour model, a CMYK image of the art's inks, its transparency as its opacity mask); each defaults to document.rasterEffectsSettings; an image larger than 64 Mpx or 32,768 px a side is refused; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}",
has_selection,
rasterize
),
@@ -670,7 +670,11 @@ fn render_pixels(
opts: &vectorcraft_render::RenderOptions,
color: RasterColorModel,
) -> Result<(vectorcraft_render::Rendered, Option<&'static str>)> {
if region.width() * region.height() * scale * scale > MAX_PIXELS || region.width() * scale > 65535.0 || region.height() * scale > 65535.0 {
// No side longer than the renderer draws: it would cut the image off there. The sides are
// measured in the renderer's whole pixels.
let side = vectorcraft_render::MAX_RASTER_SIDE;
let (w, h) = vectorcraft_render::region_pixels(region, scale);
if region.width() * region.height() * scale * scale > MAX_PIXELS || w > side || h > side {
return Err(EngineError::Other("image would be too large; lower the resolution".into()));
}
let mut r = vectorcraft_render::Renderer::new();
+7 -2
View File
@@ -719,7 +719,7 @@ fn run_pf(s: &mut Session, p: &Value, op: PathfinderOp) -> Result<Value> {
/// Pathfinder panel › Trap ([`vectorcraft_render::effects::trap`]): the traps between the selected
/// objects' leaf shapes, as one group of overprinting paths above the topmost selected object.
fn trap(s: &mut Session, p: &Value) -> Result<Value> {
use vectorcraft_render::effects::trap::{TRAP_LABEL, TrapOptions, TrapSource, trap_appearance, traps};
use vectorcraft_render::effects::trap::{TRAP_LABEL, TrapOptions, TrapSource, trap_appearance, try_traps};
const ID: &str = "object.pathfinder.trap";
let mut q = p.clone();
if let Some(t) = length_given(p, "thickness", ID)? {
@@ -758,7 +758,12 @@ fn trap(s: &mut Session, p: &Value) -> Result<Value> {
if sources.iter().filter(|s| s.fill.color().is_some()).count() < 2 {
return Err(EngineError::Other("Trap: select two or more objects filled with solid colours".into()));
}
let made = traps(&sources, &options);
let Some(made) = try_traps(&sources, &options) else {
return Err(EngineError::Other(format!(
"Trap: more than {} pairs of objects lie close enough to trap, or too many objects share a horizontal range; trap fewer objects at a time",
vectorcraft_render::effects::trap::MAX_NEAR_PAIRS
)));
};
if made.is_empty() {
return Err(EngineError::Other("Trap: no edges of different colours meet among the selected objects".into()));
}
+4 -3
View File
@@ -10,6 +10,7 @@ use vectorcraft_doc::pattern::pattern_paint;
use vectorcraft_doc::{Appearance, Document, Node, NodeId, PatternDef, TileType};
use vectorcraft_geom::{PathData, Point, Rect, SubPath, shapes};
use super::Result;
use super::clipboard::Clipboard;
use super::clipboard::resources::SwatchChoices;
use super::swatchlib::LibraryInfo;
@@ -84,11 +85,11 @@ pub fn swatch_source(w: &Swatch) -> Option<Arc<Document>> {
/// Add pattern `name` of library `lib` to `d` with the resources its art uses, as pasting art
/// painted with it does: the same pattern there already is used, and a name another pattern or
/// swatch has gets a number → the pattern's name in `d`.
pub(crate) fn import(d: &mut Document, lib: &Document, name: &str) -> String {
pub(crate) fn import(d: &mut Document, lib: &Document, name: &str) -> Result<String> {
let user = Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(pattern_paint(name), Paint::None, 0.0));
let imported = Clipboard::gather(lib, vec![user]).import_into(d, &SwatchChoices::default(), false);
let imported = Clipboard::gather(lib, vec![user]).import_into(d, &SwatchChoices::default(), false)?;
let renamed = imported.renamed.iter().find(|r| r["kind"] == "pattern" && r["from"] == name).and_then(|r| r["to"].as_str());
renamed.unwrap_or(name).to_string()
Ok(renamed.unwrap_or(name).to_string())
}
/// The pattern of `d` that is pattern `name` of library `lib`: the same tile options and art under
+1 -1
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Print Setup",
[],
None,
"{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), copiesPerSheet: false (not with tiling: as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, not turned; the placement places the grid; an artboard that doesn't fit prints one copy per sheet, with a warning), gutter: 0 (pt between copies, 0–720), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)",
"{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), copiesPerSheet: false (not with tiling: as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, not turned; the placement places the grid; an artboard that doesn't fit prints one copy per sheet, with a warning), gutter: 0 (pt between copies, 0–720), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution, or lower for a page larger than 64 Mpx or 32,768 px a side), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)",
has_doc,
setup
),
+4 -2
View File
@@ -76,9 +76,11 @@ fn bitmap_document(cmd: &str, doc: &Document, set: &PrintSettings, rich_black: b
let mut images = vec![];
for (_, r) in regions {
let area = outset(r, bleed);
// At most MAX_PIXELS, and 65535 pixels a side.
// At most MAX_PIXELS, and no side longer than the renderer draws (rounded up to whole
// pixels below).
let side = area.width().max(area.height()).max(1.0);
let scale = (doc.raster_effects_ppi / 72.0).min((MAX_PIXELS / (area.width() * area.height()).max(1.0)).sqrt()).min(65_535.0 / side);
let longest = f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 2.0;
let scale = (doc.raster_effects_ppi / 72.0).min((MAX_PIXELS / (area.width() * area.height()).max(1.0)).sqrt()).min(longest / side);
if !(scale.is_finite() && scale > 0.0) {
continue;
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -753,7 +753,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
if let (Paint::Pattern { pattern, .. }, Some(src)) = (&w.paint, &patterns) {
// The pattern comes with its swatch; a pattern the document has without one
// gets one.
let target = patternlib::import(d, src, pattern);
let target = patternlib::import(d, src, pattern)?;
let name = match patternlib::pattern_swatch(d, &target) {
Some(name) => name,
None => {
+2 -2
View File
@@ -405,7 +405,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
};
// Where the symbols land, worked out on a copy: nothing to add and nothing to place is no edit.
let mut probe = (*st.doc).clone();
let imported = clip.import_into(&mut probe, &choices, false);
let imported = clip.import_into(&mut probe, &choices, false)?;
let doc_names: Vec<String> = picked.iter().map(|n| target(&imported.renamed, n)).collect();
let centre = st.doc.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO);
let at = if place { Some(Point::new(number_or(p, "x", centre.x, C)?, number_or(p, "y", centre.y, C)?)) } else { None };
@@ -413,7 +413,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
let mut out = json!({ "library": info.id });
if imported.added > 0 || place {
let placed = s.edit("Add to Symbols", |d, sel| {
let imported = clip.import_into(d, &choices, false);
let imported = clip.import_into(d, &choices, false)?;
// Every symbol brought in (nested ones too) takes its Symbol Options along.
for sym in &clip.symbols {
let to = target(&imported.renamed, &sym.name);
+2 -2
View File
@@ -976,12 +976,12 @@ fn load(s: &mut Session, p: &Value, kinds: &[Kind]) -> Result<Value> {
}
let (chars, paras) = (kinds.contains(&Kind::Char), kinds.contains(&Kind::Para));
let mut trial = (*s.doc()?.doc).clone();
let mut placed = import_text_styles(&src, &mut trial, chars, paras);
let mut placed = import_text_styles(&src, &mut trial, chars, paras)?;
// An undo step only when the document changes. The step takes the document the import made,
// unless the edit first ends a typing session, which can change the document it starts from.
if trial != *s.doc()?.doc {
if super::typing_in_progress(s) {
placed = s.edit(label, |d, _| Ok(import_text_styles(&src, d, chars, paras)))?;
placed = s.edit(label, |d, _| import_text_styles(&src, d, chars, paras))?;
} else {
s.edit(label, |d, _| {
*d = trial;
+7 -7
View File
@@ -1105,7 +1105,7 @@ fn self_referencing_pattern_is_not_added_again() {
let different = dst.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone();
dst.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [different]})).unwrap();
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
super::super::clipboard::resources::import_text_styles(&source, target, true, true);
super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap();
assert!(dst.doc().unwrap().doc.pattern("Dots 2").is_some());
assert!(
dst.doc().unwrap().doc.pattern("Dots 2").unwrap().art[0]
@@ -1116,7 +1116,7 @@ fn self_referencing_pattern_is_not_added_again() {
);
let before = dst.doc().unwrap().doc.clone();
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true);
let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap();
assert!(again.iter().all(|s| !s.added));
assert_eq!(dst.doc().unwrap().doc, before);
}
@@ -1148,13 +1148,13 @@ fn separate_self_referencing_patterns_are_not_added_again() {
dst.execute("object.pattern.make", &json!({"name": format!("Dots{k}"), "edit": false, "ids": [different]})).unwrap();
}
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
super::super::clipboard::resources::import_text_styles(&source, target, true, true);
super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap();
for k in 0..9 {
assert!(dst.doc().unwrap().doc.pattern(&format!("Dots{k} 2")).is_some(), "{k}");
}
let before = dst.doc().unwrap().doc.clone();
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true);
let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap();
assert!(again.iter().all(|s| !s.added));
assert_eq!(dst.doc().unwrap().doc.patterns.len(), before.patterns.len());
assert_eq!(dst.doc().unwrap().doc, before);
@@ -1162,7 +1162,7 @@ fn separate_self_referencing_patterns_are_not_added_again() {
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
target.patterns.retain(|p| !p.name.starts_with("Dots8"));
let left = target.patterns.len();
let third = super::super::clipboard::resources::import_text_styles(&source, target, true, true);
let third = super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap();
assert_eq!(dst.doc().unwrap().doc.patterns.len(), left + 1);
assert!(third.iter().filter(|s| s.name != "Patterned8" && !s.name.starts_with("Patterned8 ")).all(|s| !s.added));
}
@@ -1195,13 +1195,13 @@ fn a_long_loop_of_patterns_is_not_added_again() {
dst.execute("object.pattern.make", &json!({"name": format!("Dots{k}"), "edit": false, "ids": [different]})).unwrap();
}
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
super::super::clipboard::resources::import_text_styles(&source, target, true, true);
super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap();
for k in 0..9 {
assert!(dst.doc().unwrap().doc.pattern(&format!("Dots{k} 2")).is_some(), "{k}");
}
let before = dst.doc().unwrap().doc.clone();
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true);
let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap();
assert!(again.iter().all(|s| !s.added));
assert_eq!(dst.doc().unwrap().doc.patterns.len(), before.patterns.len());
assert_eq!(dst.doc().unwrap().doc, before);
+4 -2
View File
@@ -8,7 +8,7 @@ use vectorcraft_doc::{AreaFit, Document, Justify, Node, NodeId, NodeKind, RubyAl
use vectorcraft_geom::{Affine, PathData, Vec2};
use super::edit::selected_roots;
use super::pathops::{len_param, length_given, number_given, shape_node};
use super::pathops::{length_given, number_given, shape_node};
use super::{
CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, id_param, index, number_or, ok,
str_param, switch_or, targets, text_param,
@@ -824,7 +824,9 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
let leading = match p.get("leading") {
None | Some(Value::Null) => None,
Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Some(None),
Some(_) => Some(Some(len_param(p, "leading").ok_or_else(|| bad(C, "leading must be a number or \"auto\""))?.clamp(0.1, 5000.0))),
Some(_) => {
Some(Some(length_given(p, "leading", C).ok().flatten().ok_or_else(|| bad(C, "leading must be a number or \"auto\""))?.clamp(0.1, 5000.0)))
}
};
let tracking = number_given(p, "tracking", C)?;
let justify = match text_param(p, "justify", C)? {
+2 -1
View File
@@ -106,7 +106,8 @@ impl Entry {
Entry::End(e) => format!("ended: {}", e.id()),
Entry::Report { ms, host_calls, slowest } => {
let time = ms.map_or_else(|| "time unavailable".to_string(), |ms| format!("{ms} ms"));
let mut s = format!("{time}, {host_calls} host calls");
let calls = if *host_calls == 1 { "host call" } else { "host calls" };
let mut s = format!("{time}, {host_calls} {calls}");
if !slowest.is_empty() {
let list: Vec<String> = slowest.iter().map(|c| format!("{} ×{} {} ms", c.id, c.calls, c.total_ms)).collect();
s.push_str("; slowest: ");
+307
View File
@@ -0,0 +1,307 @@
//! The members of a script's `$` object besides `$.write`, `$.writeln`, `$.evalFile`, `$.fileName`
//! and `$.includePath`, from the Dollar object
//! page (<https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/>), and
//! the locale the global `localize` reads
//! (<https://extendscript.docsforadobe.dev/extendscript-tools-features/localizing-extendscript-strings/>).
//!
//! Every value here belongs to one run: a run starts with the locale of the interface, the
//! environment values of [`ENV_NAMES`] read from the process, localization off and a timer at zero,
//! and what a script changes ends with its run. `$.setenv` never changes the process's environment.
//!
//! These are VectorCraft's own values and rules: `$.appEncoding` is always UTF-8; `$.build` and
//! `$.version` give VectorCraft's version, `$.engineName` is `VectorCraft` and `$.buildDate` is
//! `null`; `$.flags` takes only 512, which turns localization on; `$.level` and `$.memCache` are 0
//! and `$.screens` is empty; `$.os` names the system without its version; `$.strict` is whether the
//! realm reads scripts as strict mode code; `$.bp` and `$.colorPicker` throw, since scripts have no
//! debugger and cannot show a dialog; `$.gc` does nothing, since the interpreter collects its own
//! garbage; `$.getenv` and `$.setenv` know only [`ENV_NAMES`], with values of at most
//! [`MAX_ENV_BYTES`] bytes of letters, digits and `_ . - @`; and `$.sleep` waits at most
//! [`MAX_SLEEP_MS`] milliseconds, between two frames of the app, and does not wait on the web,
//! which has no clock.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::time::Duration;
use vectorcraft_js::{Data, HostError, HostItem, Localization, ScriptItem};
use super::{Host, function};
/// The longest `$.sleep`, in milliseconds.
pub const MAX_SLEEP_MS: f64 = 1_000.0;
/// The environment variables `$.getenv` reads and `$.setenv` sets.
pub const ENV_NAMES: &[&str] = &["LANG", "LC_MESSAGES", "LC_ALL", "OS"];
/// The most bytes of an environment value.
pub const MAX_ENV_BYTES: usize = 64;
/// The value of `$.flags` that turns localization on, the only one a script may set.
pub const LOCALIZE_FLAG: f64 = 512.0;
/// What `$` knows of a run.
pub(crate) struct RunInfo {
/// The locale the run starts with, such as `de_DE`.
pub locale: String,
/// The realm reads scripts as strict mode code.
pub strict: bool,
}
/// The locale for an interface language code such as `de`, `pt-br` or `zh-hant`: the language,
/// with its region after `_` where the code gives one (`pt_BR`; `zh_CN` for `zh-hans` and `zh_TW`
/// for `zh-hant`); `en` for a code that is not a language.
pub(crate) fn ui_locale(code: &str) -> String {
let mut parts = code.split(['-', '_']);
let language = parts.next().unwrap_or_default().to_ascii_lowercase();
if !(2..=3).contains(&language.len()) || !language.bytes().all(|b| b.is_ascii_lowercase()) {
return "en".into();
}
let region = match parts.next().map(str::to_ascii_lowercase).as_deref() {
Some("hans") => Some("CN".to_string()),
Some("hant") => Some("TW".to_string()),
Some(r) if r.len() == 2 && r.bytes().all(|b| b.is_ascii_lowercase()) => Some(r.to_ascii_uppercase()),
_ => None,
};
region.map_or(language.clone(), |r| format!("{language}_{r}"))
}
/// A locale a script gives `$.locale`, as `ll` or `ll_RR`: a language of two or three letters and
/// an optional region of two letters or three digits, joined by `_` or `-`, in any case.
fn script_locale(text: &str) -> Option<String> {
let mut parts = text.split(['-', '_']);
let language = parts.next()?;
let region = parts.next();
if parts.next().is_some() || !(2..=3).contains(&language.len()) || !language.bytes().all(|b| b.is_ascii_alphabetic()) {
return None;
}
let language = language.to_ascii_lowercase();
match region {
None => Some(language),
Some(r) if r.len() == 2 && r.bytes().all(|b| b.is_ascii_alphabetic()) => Some(format!("{language}_{}", r.to_ascii_uppercase())),
Some(r) if r.len() == 3 && r.bytes().all(|b| b.is_ascii_digit()) => Some(format!("{language}_{r}")),
Some(_) => None,
}
}
/// The languages whose numbers are written with a decimal comma (VectorCraft's own list).
const DECIMAL_COMMA: &[&str] = &[
"bg", "ca", "cs", "da", "de", "el", "es", "et", "fi", "fr", "hr", "hu", "id", "it", "lt", "lv", "nb", "nl", "no", "pl", "pt", "ro", "ru", "sk",
"sl", "sr", "sv", "tr", "uk", "vi",
];
/// The character `locale` writes before a number's fraction.
pub(crate) fn decimal_point(locale: &str) -> &'static str {
let language = locale.split('_').next().unwrap_or_default();
if DECIMAL_COMMA.contains(&language) { "," } else { "." }
}
/// The platform suffix of a localization object's names on this system.
pub(crate) fn platform() -> Option<&'static str> {
if cfg!(target_arch = "wasm32") {
None
} else if cfg!(windows) {
Some("Win")
} else if cfg!(target_os = "macos") {
Some("Mac")
} else {
Some("Unix")
}
}
/// `$.os`: the system, without its version.
fn os() -> &'static str {
if cfg!(target_arch = "wasm32") {
"Web"
} else if cfg!(windows) {
"Windows"
} else if cfg!(target_os = "macos") {
"macOS"
} else if cfg!(target_os = "linux") {
"Linux"
} else if cfg!(target_os = "freebsd") {
"FreeBSD"
} else {
"Unix"
}
}
/// Is `value` one an environment value may be?
fn env_value_ok(value: &str) -> bool {
value.len() <= MAX_ENV_BYTES && value.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-' | b'@'))
}
/// The values of [`ENV_NAMES`] in the process's environment, each left out when it is not one an
/// environment value may be.
fn process_env() -> Vec<(&'static str, Option<String>)> {
ENV_NAMES.iter().map(|&n| (n, std::env::var(n).ok().filter(|v| env_value_ok(v)))).collect()
}
fn one_string(args: &[Data], name: &str) -> Result<String, HostError> {
match args.first() {
Some(Data::String(s)) => Ok(s.clone()),
_ => Err(HostError::Type(format!("$.{name}: give a name, as a string"))),
}
}
fn value(v: Data) -> HostItem {
HostItem::Value(v)
}
fn text(s: impl Into<String>) -> Data {
Data::String(s.into())
}
/// The members `$` has besides `write`, `writeln`, `evalFile`, `fileName` and `includePath`.
pub(super) fn members(host: &Rc<Host>, info: RunInfo, localization: &Rc<Localization>) -> Vec<(String, HostItem)> {
let version = env!("CARGO_PKG_VERSION");
let host_locale = info.locale.clone();
localization.set_locale(&info.locale);
localization.set_platform(platform());
let env = Rc::new(RefCell::new(process_env()));
let timer = Rc::new(Cell::new(Duration::ZERO));
let getter = |f: Rc<dyn Fn() -> Data>| HostItem::Getter(function(move |_| Ok(f())));
let property = |get: Rc<dyn Fn() -> Data>, set: Rc<dyn Fn(Data) -> Result<(), HostError>>| HostItem::Property {
get: function(move |_| Ok(get())),
set: function(move |mut a| {
set(if a.is_empty() { Data::Undefined } else { a.swap_remove(0) })?;
Ok(Data::Undefined)
}),
};
let fail = |message: &'static str| HostItem::Function(function(move |_| Err(HostError::Error(message.to_string()))));
let locale = {
let (l, s) = (localization.clone(), localization.clone());
property(
Rc::new(move || text(l.locale())),
Rc::new(move |v| {
let locale = match v {
Data::Null | Data::Undefined => host_locale.clone(),
Data::String(t) if t.is_empty() => host_locale.clone(),
Data::String(t) => script_locale(&t).ok_or_else(|| HostError::Range(BAD_LOCALE.to_string()))?,
_ => return Err(HostError::Type(BAD_LOCALE.to_string())),
};
s.set_locale(&locale);
Ok(())
}),
)
};
let localize = {
let (l, s) = (localization.clone(), localization.clone());
property(
Rc::new(move || Data::Bool(l.enabled())),
Rc::new(move |v| match v {
Data::Bool(on) => {
s.set_enabled(on);
Ok(())
}
_ => Err(HostError::Type("$.localize: give true or false".into())),
}),
)
};
let flags = {
let (l, s) = (localization.clone(), localization.clone());
property(
Rc::new(move || Data::Number(if l.enabled() { LOCALIZE_FLAG } else { 0.0 })),
Rc::new(move |v| match v {
Data::Number(n) if n == 0.0 || n == LOCALIZE_FLAG => {
s.set_enabled(n == LOCALIZE_FLAG);
Ok(())
}
Data::Number(_) => Err(HostError::Range("$.flags: only 512, which turns localization on, or 0 can be set".into())),
_ => Err(HostError::Type("$.flags: give a number".into())),
}),
)
};
let decimal = {
let l = localization.clone();
getter(Rc::new(move || text(decimal_point(&l.locale()))))
};
let app_encoding = property(
Rc::new(|| text("UTF-8")),
Rc::new(|v| match v {
Data::String(t) if t.eq_ignore_ascii_case("utf-8") || t.eq_ignore_ascii_case("utf8") => Ok(()),
Data::String(_) => Err(HostError::Range("$.appEncoding: scripts here read and write UTF-8 only".into())),
_ => Err(HostError::Type("$.appEncoding: give an encoding's name, as a string".into())),
}),
);
let mem_cache =
property(Rc::new(|| Data::Number(0.0)), Rc::new(|_| Err(HostError::Error("$.memCache: the memory cache size cannot be set".into()))));
let hires_timer = {
let (h, last) = (host.clone(), timer.clone());
getter(Rc::new(move || {
let Some(now) = h.elapsed() else { return Data::Number(0.0) };
let since = now.saturating_sub(last.replace(now));
#[allow(clippy::cast_precision_loss)]
Data::Number(since.as_micros() as f64)
}))
};
let getenv = {
let (h, env) = (host.clone(), env.clone());
function(move |a| {
let name = one_string(&a, "getenv")?;
h.charge_call(name.len() as u64 + 2)?;
let env = env.borrow();
let found = env.iter().find(|(n, _)| *n == name).and_then(|(_, v)| v.clone());
Ok(found.map_or(Data::Null, Data::String))
})
};
let setenv = {
let (h, env) = (host.clone(), env.clone());
function(move |a| {
let name = one_string(&a, "setenv")?;
let Some(Data::String(v)) = a.get(1) else { return Err(HostError::Type("$.setenv: give a value, as a string".into())) };
h.charge_call((name.len() + v.len()) as u64 + 4)?;
let mut env = env.borrow_mut();
let Some(slot) = env.iter_mut().find(|(n, _)| *n == name) else {
return Err(HostError::Error(format!("$.setenv: only {} can be set", ENV_NAMES.join(", "))));
};
if !env_value_ok(v) {
return Err(HostError::Range(format!("$.setenv: a value has at most {MAX_ENV_BYTES} letters, digits and _ . - @")));
}
slot.1 = Some(v.clone());
Ok(Data::Undefined)
})
};
let sleep = {
let h = host.clone();
function(move |a| {
let ms = match a.first() {
Some(Data::Number(n)) if (0.0..=MAX_SLEEP_MS).contains(n) => *n,
Some(Data::Number(_)) => return Err(HostError::Range(format!("$.sleep: give a number of milliseconds from 0 to {MAX_SLEEP_MS}"))),
_ => return Err(HostError::Type("$.sleep: give a number of milliseconds".into())),
};
h.charge_call(8)?;
h.sleep(Duration::from_secs_f64(ms / 1000.0))?;
Ok(Data::Undefined)
})
};
let about = text(format!("VectorCraft script engine {version}"));
vec![
("about".into(), HostItem::Function(function(move |_| Ok(about.clone())))),
("appEncoding".into(), app_encoding),
("bp".into(), fail("$.bp: scripts here have no debugger")),
("build".into(), value(text(version))),
("buildDate".into(), value(Data::Null)),
("colorPicker".into(), fail("$.colorPicker: scripts cannot show a dialog yet")),
("decimalPoint".into(), decimal),
("engineName".into(), value(text("VectorCraft"))),
("error".into(), HostItem::Script(ScriptItem::LastError)),
("flags".into(), flags),
("gc".into(), HostItem::Function(function(|_| Ok(Data::Undefined)))),
("getenv".into(), HostItem::Function(getenv)),
("global".into(), HostItem::Script(ScriptItem::Global)),
("hiresTimer".into(), hires_timer),
("level".into(), value(Data::Number(0.0))),
("line".into(), HostItem::Script(ScriptItem::Line)),
("locale".into(), locale),
("localize".into(), localize),
("memCache".into(), mem_cache),
("os".into(), value(text(os()))),
("screens".into(), value(Data::Array(Vec::new()))),
("setenv".into(), HostItem::Function(setenv)),
("sleep".into(), HostItem::Function(sleep)),
("stack".into(), HostItem::Script(ScriptItem::Stack)),
("strict".into(), value(Data::Bool(info.strict))),
("version".into(), value(text(version))),
]
}
const BAD_LOCALE: &str = "$.locale: give a locale such as \"en\" or \"de_DE\", or null for the interface's";
+23 -12
View File
@@ -1,8 +1,11 @@
//! A script's preprocessor lines, read before it runs, as the public guide lists them
//! (<https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/>):
//! `#include` and `//@include` put the text of another file in place of the line, `#includepath`
//! sets the folders `#include` looks in, `#script` names the run, and `#target` and `#strict off`
//! are accepted. The same lines are read in a file `$.evalFile` runs.
//! sets the folders `#include` looks in, `#script` names the run, `#strict on` turns on the checks
//! of `$.strict` (<https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/>:
//! a write to a read-only property is a runtime error, and objects that take no new properties
//! refuse them), and `#target` and `#strict off` are accepted. The same lines are read in a file
//! `$.evalFile` runs.
//!
//! VectorCraft's own rules:
//!
@@ -13,9 +16,15 @@
//! the `JSINCLUDE` environment variable. A later `#includepath` replaces the list.
//! - Files are read through the script's [`ScriptScope`]: a refused path ends the run, except a
//! `JSINCLUDE` folder outside the scope, which is passed over.
//! - `#target` takes any value and changes nothing. `#targetengine`, `#engine` and `#strict on` are
//! refused as unsupported, and any other `#name` as unknown; `//@name` with another name stays a
//! comment.
//! - `#target` takes any value and changes nothing. `#targetengine` and `#engine` are refused as
//! unsupported, and any other `#name` as unknown; `//@name` with another name stays a comment.
//! - The last `#strict on` or `#strict off` of the script and the files it includes sets the checks
//! for the whole run; without one they are off. A write to a property that has only a getter is
//! checked as a read-only one, the objects that take no new properties are those that are not
//! extensible (`Object.preventExtensions`, `Object.seal`, `Object.freeze`), and the rest of ES5
//! strict mode is left to `'use strict'`. Code that `eval` and `Function` compile takes the
//! checks of the code that compiles it; a file `$.evalFile` runs takes its own `#strict` line if
//! it has one, and otherwise the caller's.
//! - A run reads at most [`MAX_DEPTH`] levels of nested files, [`MAX_FILES`] files,
//! [`MAX_TOTAL_BYTES`] of source in all (the script's own included) and 8 MiB a file, and looks up
//! at most [`MAX_PROBES`] paths. A file may be included again once its earlier inclusion has
@@ -97,12 +106,13 @@ pub(crate) struct Failure {
pub column: u32,
}
/// A source with its preprocessor lines read: the text to run, where its lines came from, and the
/// name its `#script` line gave.
/// A source with its preprocessor lines read: the text to run, where its lines came from, the
/// name its `#script` line gave, and what its last `#strict` line set (`None` without one).
pub(crate) struct Expanded {
pub source: String,
pub map: SourceMap,
pub name: Option<String>,
pub strict: Option<bool>,
}
/// A file being run: its name for people and the folder its relative paths start from (none for a
@@ -130,13 +140,14 @@ pub(crate) struct Files {
}
/// The output being built: its UTF-16 text, the line it is on, the units since that line began,
/// and whether its last unit is a carriage return.
/// whether its last unit is a carriage return, and what the last `#strict` line so far set.
struct Out {
units: Vec<u16>,
line: u32,
column: u32,
after_cr: bool,
map: SourceMap,
strict: Option<bool>,
}
impl Out {
@@ -330,7 +341,7 @@ impl Files {
/// `text`, the file `shown` in folder `dir`, with its preprocessor lines read. With `script`,
/// its `#script` line names the run.
fn expand(&mut self, shown: Arc<str>, dir: Option<PathBuf>, text: &str, script: bool) -> Result<Expanded, Failure> {
let mut out = Out { units: Vec::with_capacity(text.len()), line: 1, column: 0, after_cr: false, map: SourceMap::default() };
let mut out = Out { units: Vec::with_capacity(text.len()), line: 1, column: 0, after_cr: false, map: SourceMap::default(), strict: None };
let mut name = None;
self.file(&mut out, shown, dir.as_deref(), text, script, &mut name)?;
let source = String::from_utf16(&out.units).map_err(|_| Failure {
@@ -341,7 +352,7 @@ impl Files {
line: 0,
column: 0,
})?;
Ok(Expanded { source, map: out.map, name })
Ok(Expanded { source, map: out.map, name, strict: out.strict })
}
/// Adds file `shown` (in folder `dir`, with text `text`) to `out`, its included files in place
@@ -414,8 +425,8 @@ impl Files {
}
}
Some(DirectiveName::Strict) => match value.to_ascii_lowercase().as_str() {
"off" => {}
"on" => return Err(at(FailKind::Syntax, format!("unsupported strict checking: {label} on"))),
"off" => out.strict = Some(false),
"on" => out.strict = Some(true),
_ => return Err(at(FailKind::Syntax, format!("{label} takes on or off"))),
},
Some(DirectiveName::TargetEngine) => return Err(at(FailKind::Syntax, format!("unsupported script directive: {label}"))),
+168 -46
View File
@@ -31,13 +31,15 @@
//! A run writes to the Script Console with `$.write` and `$.writeln`, and `vc.log` adds a line
//! there too. The session's console gets a start line, the run's output, its uncaught error, an end
//! line and a report line; the run's report holds its own output and error, its time, its host
//! calls by kind and its slowest commands.
//! calls by kind and its slowest commands. The rest of `$`, and the global `localize` and
//! `toSource`, are in [`dollar`].
//!
//! `alert`, `confirm` and `prompt` open dialogs ([`dialogs`]): in the app the run waits for the
//! person's answer, and a run with nobody to ask takes its answers from `script.run`'s `answers`.
pub mod console;
pub mod dialogs;
pub mod dollar;
pub mod include;
pub use dialogs::{DialogAnswer, DialogKind, ScriptDialog};
@@ -94,7 +96,7 @@ const MAX_BRIDGE_BYTES: u64 = 4 << 30;
/// The version `vc.api.version` gives.
pub const API_VERSION: &str = "1.0";
/// The features `vc.api.has` knows.
pub const API_FEATURES: &[&str] = &["run", "document", "commands", "log", "args", "console", "dialogs"];
pub const API_FEATURES: &[&str] = &["run", "document", "commands", "log", "args", "console", "dialogs", "dollar"];
/// The most commands a run's report lists in `slowestCommands`.
pub const SLOWEST_COMMANDS: usize = 5;
@@ -153,6 +155,8 @@ pub(crate) struct Request {
pub source: String,
pub args: Map<String, Value>,
pub fuel: u64,
/// The locale the run starts with (`$.locale`).
pub locale: String,
/// The answers to the dialogs of a run with nobody to ask, in order.
pub answers: Vec<DialogAnswer>,
}
@@ -166,21 +170,27 @@ thread_local! {
static NEXT_JOB: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
/// A session's hold on its running job: while one exists the lease is held. Dropping it ends the job.
/// It keeps the run's number and its name as console lines give it, so the run's console entry can
/// end when the job is gone.
#[derive(Debug)]
pub struct JobKey(u64);
pub struct JobKey {
id: u64,
run: u64,
name: Arc<str>,
}
impl Drop for JobKey {
fn drop(&mut self) {
let _ = JOBS.try_with(|jobs| {
if let Ok(mut jobs) = jobs.try_borrow_mut() {
jobs.remove(&self.0);
jobs.remove(&self.id);
}
});
}
}
fn with_job<T>(key: &JobKey, f: impl FnOnce(&mut Job) -> T) -> Option<T> {
JOBS.with(|jobs| jobs.borrow_mut().get_mut(&key.0).map(f))
JOBS.with(|jobs| jobs.borrow_mut().get_mut(&key.id).map(f))
}
/// The state a script changes that a rollback puts back.
@@ -496,14 +506,19 @@ struct Host {
script_map: RefCell<include::SourceMap>,
/// The same for each source `$.evalFile` gave, in order.
eval_maps: RefCell<Vec<include::SourceMap>>,
/// The last error `$.evalFile` raised for a file it could not run, with the file, line and
/// column that error is at.
eval_failure: RefCell<Option<EvalFailure>>,
/// The file, line and column of each error `$.evalFile` raised for a file it could not run, in
/// order: the error's mark ([`HostError::Marked`]) is its index.
eval_failures: RefCell<Vec<(Arc<str>, u32, u32)>>,
/// When the run started, where the platform has a clock.
started: Option<std::time::Instant>,
/// The time since [`Host::started`] at which a `$.sleep` ends.
wake_at: Cell<Option<std::time::Duration>>,
/// Set while a host function runs where the run can stop at the call ([`Realm::wait_flag`]).
can_wait: Rc<Cell<bool>>,
/// What the last `#strict` line of the file `$.evalFile` last gave set (`None` without one).
eval_strict: Cell<Option<bool>>,
}
/// An error `$.evalFile` raised, as thrown, and the file, line and column it is at.
type EvalFailure = (String, Arc<str>, u32, u32);
/// A run's host calls by kind, as its report gives them in `hostCallCounts`.
#[derive(Default)]
struct Counts {
@@ -576,10 +591,64 @@ impl Host {
pending.end_line(|line, cut| self.line(console::Entry::Output(line), cut));
}
}
/// The time since the run started; none without a clock.
fn elapsed(&self) -> Option<std::time::Duration> {
#[cfg(test)]
if TEST_NO_CLOCK.with(Cell::get) {
return None;
}
#[cfg(test)]
if let Some(d) = TEST_ELAPSED.with(Cell::get) {
return Some(d);
}
self.started.map(|s| s.elapsed())
}
/// `$.sleep`: ends the slice, and the run does not go on until `d` has passed. Without a clock
/// it does not wait. A getter, a conversion, a built-in's callback or a file `$.evalFile` runs
/// cannot stop at the call, so a wait there is an error, with or without a clock.
fn sleep(&self, d: std::time::Duration) -> std::result::Result<(), HostError> {
if d.is_zero() {
return Ok(());
}
if !self.can_wait.get() {
return Err(HostError::Error(
"$.sleep: a run cannot wait inside a callback of a built-in function, a getter, a conversion or a file $.evalFile runs; call it from the script's own code"
.into(),
));
}
if let Some(now) = self.elapsed() {
self.wake_at.set(Some(now.saturating_add(d)));
self.pause.set(true);
}
Ok(())
}
/// The time left of a `$.sleep`, or none when the run is not asleep.
fn asleep(&self) -> Option<std::time::Duration> {
let wake = self.wake_at.get()?;
let left = self.elapsed().map_or(std::time::Duration::ZERO, |now| wake.saturating_sub(now));
if left.is_zero() {
self.wake_at.set(None);
return None;
}
Some(left)
}
fn over(&self, limit: &str) -> HostError {
HostError::Abort(limit.to_string())
}
/// `error`, marked as an error at `file`, `line` and `column`, the place [`located`] gives when
/// the run ends with it uncaught.
fn placed(&self, error: HostError, file: Arc<str>, line: u32, column: u32) -> HostError {
let mut places = self.eval_failures.borrow_mut();
let Ok(mark) = u32::try_from(places.len()) else { return error };
places.push((file, line, column));
HostError::Marked { error: Box::new(error), mark }
}
fn charge_call(&self, bytes: u64) -> std::result::Result<(), HostError> {
let calls = self.calls.get() + 1;
self.calls.set(calls);
@@ -934,8 +1003,9 @@ fn each_line(text: &str, mut done: impl FnMut(String, bool)) {
/// The `$` object: `$.write(...values)` writes the values to the Script Console as `String(value)`
/// gives them, one after another, and `$.writeln(...values)` does the same and ends the line.
/// `$.evalFile(path[, timeout])` runs a file and gives its last value, `$.fileName` is the file
/// being run and `$.includePath` the `#includepath` list.
fn dollar_object(host: &Rc<Host>) -> Vec<(String, HostItem)> {
/// being run and `$.includePath` the `#includepath` list. Its other members are
/// [`dollar::members`].
fn dollar_object(host: &Rc<Host>, info: dollar::RunInfo, localization: &Rc<vectorcraft_js::Localization>) -> Vec<(String, HostItem)> {
let writer = |end: bool| {
let h = host.clone();
HostItem::TextFunction(function(move |a| {
@@ -957,15 +1027,22 @@ fn dollar_object(host: &Rc<Host>) -> Vec<(String, HostItem)> {
let eval_file = {
let h = host.clone();
let end = host.clone();
HostItem::Evaluator { source: function(move |a| eval_file(&h, a)), end: Rc::new(move || end.files.borrow_mut().end_eval()) }
let strict = host.clone();
HostItem::Evaluator {
source: function(move |a| eval_file(&h, a)),
end: Rc::new(move || end.files.borrow_mut().end_eval()),
strict_writes: Rc::new(move || strict.eval_strict.get()),
}
};
vec![
let mut items = vec![
("write".into(), writer(false)),
("writeln".into(), writer(true)),
("evalFile".into(), eval_file),
("fileName".into(), getter(include::Files::file_name)),
("includePath".into(), getter(include::Files::include_path)),
]
];
items.extend(dollar::members(host, info, localization));
items
}
/// `$.evalFile(path[, timeout])`: the source of the file `path` names, relative to the file being
@@ -987,14 +1064,12 @@ fn eval_file(host: &Host, args: Vec<Data>) -> std::result::Result<Data, HostErro
host.charge_call(path.len() as u64 + 2)?;
let expanded = host.files.borrow_mut().eval_file(path).map_err(|f| {
let message = if f.line > 0 { format!("{}:{}:{}: {}", f.file, f.line, f.column, f.message) } else { f.message };
if f.line > 0 {
*host.eval_failure.borrow_mut() = Some((message.clone(), f.file.clone(), f.line, f.column));
}
match f.kind {
let error = match f.kind {
include::FailKind::Limit => HostError::Abort(message),
include::FailKind::Syntax => HostError::Syntax(message),
include::FailKind::Error => HostError::Error(message),
}
};
if f.line > 0 { host.placed(error, f.file, f.line, f.column) } else { error }
})?;
// A file without preprocessor lines is parsed here first, so its syntax error names its place.
let options = vectorcraft_js::Options { max_source_units: include::MAX_TOTAL_BYTES, ..vectorcraft_js::Options::default() };
@@ -1002,10 +1077,10 @@ fn eval_file(host: &Host, args: Vec<Data>) -> std::result::Result<Data, HostErro
host.files.borrow_mut().end_eval();
let (_, file, line, column) = expanded.map.locate(e.line, e.column);
let message = format!("{file}:{line}:{column}: {}", e.message);
*host.eval_failure.borrow_mut() = Some((message.clone(), file, line, column));
return Err(HostError::Syntax(message));
return Err(host.placed(HostError::Syntax(message), file, line, column));
}
host.eval_maps.borrow_mut().push(expanded.map);
host.eval_strict.set(expanded.strict);
Ok(Data::String(expanded.source))
}
@@ -1095,18 +1170,20 @@ fn preprocessor_error(f: include::Failure) -> (RunError, Option<Arc<str>>) {
(RunError { kind, message, line: f.line, column: f.column }, file)
}
/// `e`, which the realm raised in source `tag` ([`Realm::error_source`]), at the line and column of
/// the file it is in, with that file's name unless it is the script's own.
fn located(host: &Host, tag: u32, e: &RunError) -> (RunError, Option<Arc<str>>) {
/// `e`, which the realm raised in source `tag` ([`Realm::error_source`]) with host error mark `mark`
/// ([`Realm::error_mark`]), at the line and column of the file it is in, with that file's name
/// unless it is the script's own.
fn located(host: &Host, tag: u32, mark: Option<u32>, e: &RunError) -> (RunError, Option<Arc<str>>) {
let map = |m: &include::SourceMap, own: bool| {
let (index, file, line, column) = m.locate(e.line, e.column);
let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, line, column) } else { e.message.clone() };
(RunError { kind: e.kind.clone(), message, line, column }, (!own || index > 0).then_some(file))
};
// An error `$.evalFile` raised for a file it could not run is at that file's place.
if let Some((_, file, line, column)) = host.eval_failure.borrow().as_ref().filter(|(thrown, ..)| e.message.contains(thrown.as_str())) {
let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, *line, *column) } else { e.message.clone() };
return (RunError { kind: e.kind.clone(), message, line: *line, column: *column }, Some(file.clone()));
// An error `$.evalFile` raised for a file it could not run, and no script caught, is at that
// file's place.
if let Some((file, line, column)) = mark.and_then(|m| host.eval_failures.borrow().get(m as usize).cloned()) {
let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, line, column) } else { e.message.clone() };
return (RunError { kind: e.kind.clone(), message, line, column }, Some(file));
}
if e.line == 0 {
return (e.clone(), None);
@@ -1173,6 +1250,15 @@ impl Session {
!self.script_stop.is_set() && self.script_key.as_ref().and_then(|k| with_job(k, |j| j.host.waiting.get())).unwrap_or(false)
}
/// The time left of the running script's `$.sleep`, or none when it is not asleep. A run asked
/// to stop is not asleep: the next [`Session::script_pump`] ends it.
pub fn script_sleep_left(&self) -> Option<std::time::Duration> {
if self.script_stop.is_set() {
return None;
}
self.script_key.as_ref().and_then(|k| with_job(k, |j| j.host.asleep())).flatten()
}
/// The dialog (`alert`, `confirm` or `prompt`) the running script waits at for the person's
/// answer. A run asked to stop no longer waits: the next [`Session::script_pump`] ends it.
pub fn script_dialog(&self) -> Option<ScriptDialog> {
@@ -1222,12 +1308,8 @@ impl Session {
/// ([`Self::script_report`]). False with none running.
pub fn script_abandon(&mut self) -> bool {
let Some(key) = self.script_key.take() else { return false };
let Some(job) = JOBS.with(|jobs| jobs.borrow_mut().remove(&key.0)) else {
drop(key);
raise_ids(self);
self.script_stop.clear();
self.reset_tool();
cmd::compositefonts::sync(self);
let Some(job) = JOBS.with(|jobs| jobs.borrow_mut().remove(&key.id)) else {
self.script_gone(key, console::Ending::Stopped);
return true;
};
drop(key);
@@ -1237,6 +1319,19 @@ impl Session {
true
}
/// Ends the run whose job is no longer held: its console entry ends with `ending`, with no
/// report line, since its time and host calls went with the job, and the session's state is
/// put back as at the end of any run.
fn script_gone(&mut self, key: JobKey, ending: console::Ending) {
let line = console::ConsoleLine { run: key.run, name: key.name.clone(), entry: console::Entry::End(ending) };
self.script_console.push(line, false);
drop(key);
raise_ids(self);
self.script_stop.clear();
self.reset_tool();
cmd::compositefonts::sync(self);
}
/// A script can't start while a document holds a drag in progress or an undo group, which its
/// one undo step could not take in.
fn settle_for_script(&mut self) -> Result<()> {
@@ -1270,6 +1365,7 @@ impl Session {
};
let limits = Limits { fuel: request.fuel, heap_bytes: HEAP_BYTES, ..Limits::default() };
let options = Options { max_source_units: include::MAX_TOTAL_BYTES.saturating_mul(2), ..Options::default() };
let strict = options.strict;
let mut realm = Realm::with_options(options, limits);
realm.share_interrupt(&self.script_stop);
realm.set_data_limits(DataLimits::default());
@@ -1301,22 +1397,30 @@ impl Session {
files: RefCell::new(files),
script_map: RefCell::default(),
eval_maps: RefCell::default(),
eval_failure: RefCell::default(),
eval_failures: RefCell::default(),
started: clock(),
wake_at: Cell::new(None),
can_wait: realm.wait_flag(),
eval_strict: Cell::new(None),
});
let args = to_data(&Value::Object(request.args), &DataLimits::default(), 0, &mut (0, 0))
.map_err(|place| EngineError::BadParams { cmd: "script.run".into(), msg: format!("`args{place}` is nested more than 64 levels deep") })?;
let line = console::ConsoleLine { run: host.run, name: host.name.clone(), entry: console::Entry::Start };
self.script_console.push(line, false);
let info = dollar::RunInfo { locale: request.locale, strict };
let localization = realm.localization();
let started = match expanded {
Err(f) => Err(preprocessor_error(f)),
Ok(expanded) => {
*host.script_map.borrow_mut() = expanded.map;
realm.set_strict_writes(expanded.strict == Some(true));
realm
.define_host_object("vc", vc_object(&host, args))
.and_then(|()| realm.define_host_object("$", dollar_object(&host)))
.add_script_extensions()
.and_then(|()| realm.define_host_object("vc", vc_object(&host, args)))
.and_then(|()| realm.define_host_object("$", dollar_object(&host, info, &localization)))
.and_then(|()| dialogs::install(&mut realm, &host))
.and_then(|()| realm.start(&expanded.source))
.map_err(|e| located(&host, realm.error_source(), &e))
.map_err(|e| located(&host, realm.error_source(), realm.error_mark(), &e))
}
};
if let Err((e, file)) = started {
@@ -1327,10 +1431,11 @@ impl Session {
return Err(EngineError::Other(message));
}
let scope = crate::file_access::ScopeHandle::capture();
let (run, line_name) = (host.run, host.name.clone());
let job = Job { realm, host, world: World::working(self), start_uids: open, name: name.clone(), scope };
let id = NEXT_JOB.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
JOBS.with(|jobs| jobs.borrow_mut().insert(id, job));
self.script_key = Some(JobKey(id));
self.script_key = Some(JobKey { id, run, name: line_name });
Ok(name)
}
@@ -1338,14 +1443,15 @@ impl Session {
/// run has ended: its report, or the error that ended it, with the documents put back.
pub fn script_pump(&mut self) -> Option<Result<Value>> {
let key = self.script_key.take()?;
let Some(mut job) = JOBS.with(|jobs| jobs.borrow_mut().remove(&key.0)) else {
let Some(mut job) = JOBS.with(|jobs| jobs.borrow_mut().remove(&key.id)) else {
self.script_gone(key, console::Ending::Internal);
return Some(Err(EngineError::Other(JOB_GONE.to_string())));
};
if let Some(e) = job.host.dialogs.take_failure() {
return Some(self.script_failed(job, &e));
}
if (job.host.waiting.get() || job.host.dialogs.is_waiting()) && !self.script_stop.is_set() {
JOBS.with(|jobs| jobs.borrow_mut().insert(key.0, job));
if (job.host.waiting.get() || job.host.asleep().is_some() || job.host.dialogs.is_waiting()) && !self.script_stop.is_set() {
JOBS.with(|jobs| jobs.borrow_mut().insert(key.id, job));
self.script_key = Some(key);
return None;
}
@@ -1367,7 +1473,7 @@ impl Session {
}
Ok(Err(e)) => Some(self.script_failed(job, &e)),
Ok(Ok(Progress::Paused)) => {
JOBS.with(|jobs| jobs.borrow_mut().insert(key.0, job));
JOBS.with(|jobs| jobs.borrow_mut().insert(key.id, job));
self.script_key = Some(key);
None
}
@@ -1388,7 +1494,7 @@ impl Session {
fn script_failed(&mut self, job: Job, e: &RunError) -> Result<Value> {
job.host.dialogs.stopped();
let fuel_left = job.realm.fuel_left();
let (e, file) = located(&job.host, job.realm.error_source(), e);
let (e, file) = located(&job.host, job.realm.error_source(), job.realm.error_mark(), e);
let mut report = failure_report(&job.name, &e, file.as_deref(), &job.host, fuel_left, job.realm.limits().fuel.saturating_sub(fuel_left));
close_run(self, &job.host, &mut report, ending(&e.kind));
drop(job);
@@ -1486,6 +1592,11 @@ impl Session {
if self.script_waiting() || self.script_dialog().is_some() {
self.script_stop.set();
}
// A `$.sleep` waits here, where nothing is drawn, instead of turning the loop over.
#[cfg(not(target_arch = "wasm32"))]
if let Some(left) = self.script_sleep_left() {
std::thread::sleep(left.min(std::time::Duration::from_millis(100)));
}
}
}
}
@@ -1638,6 +1749,13 @@ fn run_slice(s: &mut Session, job: &mut Job) -> std::result::Result<std::result:
r
}
/// Tests: removes the session's running job while the session keeps its hold on it. → whether a
/// job was removed.
#[cfg(test)]
pub(crate) fn forget_job(s: &Session) -> bool {
s.script_key.as_ref().is_some_and(|k| JOBS.with(|jobs| jobs.borrow_mut().remove(&k.id)).is_some())
}
/// The objects a run may add before it is asked to continue or stop.
fn growth_objects() -> u64 {
#[cfg(test)]
@@ -1666,4 +1784,8 @@ thread_local! {
pub(crate) static TEST_GROWTH: Cell<Option<u64>> = const { Cell::new(None) };
/// Tests: the `JSINCLUDE` folders in place of the environment's.
pub(crate) static TEST_JSINCLUDE: RefCell<Option<Vec<std::path::PathBuf>>> = const { RefCell::new(None) };
/// Tests: the time since a run started, in place of its clock's.
pub(crate) static TEST_ELAPSED: Cell<Option<std::time::Duration>> = const { Cell::new(None) };
/// Tests: runs have no clock, as in a wasm build.
pub(crate) static TEST_NO_CLOCK: Cell<bool> = const { Cell::new(false) };
}
+1
View File
@@ -152,6 +152,7 @@ mod saveoptions;
mod scalestrokes;
mod script_console;
mod script_dialogs;
mod script_dollar;
mod script_folder;
mod script_include;
mod script_run;
+25
View File
@@ -368,6 +368,31 @@ fn a_transparent_image_is_flattened_with_the_art_under_it_which_stays() {
assert_eq!(colour_at(page, 10.0, 20.0), [255, 0, 0], "the square outside the image");
}
/// A transparent image longer than the renderer draws at its own resolution (32,768 pixels a
/// side) is flattened whole, at a lower resolution.
#[test]
fn a_long_transparent_image_is_flattened_whole() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 4600})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 4600})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
let p = json!({"name": "i.png", "dataBase64": vectorcraft_format::base64_encode(&png(2000, 1600, [0, 0, 0, 128])), "at": [50, 50]});
s.execute("file.place", &p).unwrap();
// 10 × 4500 pt: about 600 ppi, so 37,500 pixels long.
s.execute("object.setBounds", &json!({"x": 10, "y": 10, "width": 10, "height": 4500, "reference": 0})).unwrap();
let before = [colour_at(&s.doc().unwrap().doc, 15.0, 100.0), colour_at(&s.doc().unwrap().doc, 15.0, 4400.0)];
assert!(before.iter().all(|c| c[0] < 200), "the image darkens the red: {before:?}");
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap();
assert!(warnings(&r).iter().any(|w| w.contains("flattened with the art under them")), "{r}");
s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap();
let d = &s.doc().unwrap().doc;
let after = [colour_at(d, 15.0, 100.0), colour_at(d, 15.0, 4400.0)];
for (a, b) in before.iter().zip(&after) {
assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(*b) <= 3), "{before:?} {after:?}");
}
}
/// A `size`×`size` PNG of a black disk of radius `r` pixels with an anti-aliased edge, clear
/// around it, 72 ppi.
fn disk(size: u32, r: f64) -> Vec<u8> {
+36
View File
@@ -1461,6 +1461,42 @@ fn rasterize_replaces_selection_with_image() {
assert_eq!(selected(&s), vec![img]);
}
/// Rasterize refuses art that would be longer than the renderer draws (32,768 pixels a side), and
/// the art stays as it was; at a resolution that fits, the image covers all of it.
#[test]
fn rasterize_refuses_an_image_longer_than_the_renderer_draws() {
let mut s = session();
let a = rect(&mut s, 10.0, 20.0, 100.0, 5569.0);
sel(&mut s, &[a]);
let vb = s.doc().unwrap().doc.bounds_of(&[a], true).unwrap();
let before = (undo_len(&s), node(&s, a));
let e = s.execute("object.rasterize", &json!({"ppi": 600, "padding": 0})).unwrap_err().to_string();
assert!(e.contains("too large"), "{e}");
assert_eq!((undo_len(&s), node(&s, a)), before);
let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 400, "padding": 0})).unwrap());
let n = node(&s, img);
let NodeKind::Image(im) = &n.kind else { panic!() };
assert!(im.height <= vectorcraft_render::MAX_RASTER_SIDE, "{} pixels", im.height);
let b = n.geometric_bounds().unwrap();
assert!(close(b.y0, vb.y0) && b.y1 >= vb.y1, "{b:?} covers {vb:?}");
}
/// Art that snaps to exactly 32,768 pixels a side is rasterized, wherever it is on the canvas.
#[test]
fn rasterize_takes_art_that_snaps_to_32768_pixels_a_side() {
let scale = 400.0 / 72.0;
for y in [100_000.0, 250_000.0, 333_333.3, 777_777.7] {
let mut s = session();
let probe = rect(&mut s, 10.0, y, 100.0, 5898.0);
let vb = s.doc().unwrap().doc.bounds_of(&[probe], true).unwrap();
let h = 5898.0 + (32_767.8 / scale - vb.height());
let a = rect(&mut s, 200.0, y, 100.0, h);
sel(&mut s, &[a]);
let r = s.execute("object.rasterize", &json!({"ppi": 400, "padding": 0})).unwrap_or_else(|e| panic!("at y = {y}: {e}"));
assert_eq!(r["height"].as_u64(), Some(u64::from(vectorcraft_render::MAX_RASTER_SIDE)), "at y = {y}: {r}");
}
}
/// Rasterize refuses a resolution given as text or as another value that is not a number, and the
/// selected art stays as it was.
#[test]
+22
View File
@@ -130,6 +130,28 @@ fn print_as_bitmap_prints_an_image_of_each_artboard() {
assert!(crate::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().is_none());
}
/// Print as Bitmap of an artboard longer than the renderer draws at the raster effects resolution
/// (32,768 pixels a side) prints an image of all of it, at a lower resolution.
#[test]
fn print_as_bitmap_prints_all_of_a_long_artboard() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 9000, "height": 100})).unwrap();
s.execute("document.rasterEffectsSettings", &json!({"resolution": 300})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 9000, "height": 100})).unwrap();
let set = settings(json!({"advanced": {"printAsBitmap": true}}));
let saved = s.prefs.flattener_presets.clone();
let bitmap = crate::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().unwrap();
let mut placed = vec![];
bitmap.walk(|n| {
if let NodeKind::Image(im) = &n.kind {
placed.push((im.width, im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)))));
}
});
let [(width, b)] = placed[..] else { panic!("one image: {placed:?}") };
assert!(width <= vectorcraft_render::MAX_RASTER_SIDE, "{width} pixels");
assert!(b.x0 <= 0.0 && b.x1 >= 9000.0, "the image covers the artboard: {b:?}");
}
/// Appearance of Black › Printing / Exporting (#394): Print as Bitmap draws pure CMYK black as RGB
/// black when `rich_black` is on.
#[test]
+49
View File
@@ -170,6 +170,55 @@ fn a_stopped_run_reports_its_diagnostics_and_its_unended_line() {
assert_eq!(texts(&mut s, "end"), ["ended: stopped"]);
}
/// A report line counts one host call in the singular, and gives a run without a clock as "time
/// unavailable".
#[test]
fn a_report_line_counts_one_host_call_in_the_singular_and_says_when_time_is_unavailable() {
use crate::script::console::{Entry, Slow};
let report = |ms: Option<u64>, host_calls: u64| Entry::Report { ms, host_calls, slowest: vec![] }.text();
assert_eq!(report(Some(3), 0), "3 ms, 0 host calls");
assert_eq!(report(Some(3), 1), "3 ms, 1 host call");
assert_eq!(report(Some(0), 2), "0 ms, 2 host calls");
assert_eq!(report(None, 0), "time unavailable, 0 host calls");
assert_eq!(report(None, 1), "time unavailable, 1 host call");
assert_eq!(report(None, 2), "time unavailable, 2 host calls");
let slowest = vec![Slow { id: "a.b".into(), calls: 1, total_ms: 4, max_ms: 4 }];
assert_eq!(Entry::Report { ms: None, host_calls: 1, slowest }.text(), "time unavailable, 1 host call; slowest: a.b ×1 4 ms");
}
/// A run whose job is gone still ends its console entry, once, with its own number and name and no
/// report line: as stopped when it is abandoned, as an internal fault when a slice finds it gone.
/// The session can start the next run.
#[test]
fn a_run_whose_job_is_gone_still_ends_its_console_entry() {
for abandon in [true, false] {
let mut s = Session::new();
s.script_frames = true;
run(&mut s, "$.writeln('one'); while (true) {}").unwrap();
assert!(s.script_pump().is_none());
assert!(script::forget_job(&s));
if abandon {
assert!(s.script_abandon());
} else {
let e = s.script_pump().unwrap().unwrap_err();
assert_eq!(e.to_string(), "the script's run is no longer available");
}
assert!(!s.script_running());
let all = lines(&mut s);
let kinds: Vec<&str> = all.iter().map(|(_, k, _)| k.as_str()).collect();
assert_eq!(kinds, ["start", "output", "end"], "{all:?}");
assert_eq!(all[2].2, if abandon { "ended: stopped" } else { "ended: internal" });
let c = s.execute("script.console", &json!({})).unwrap();
assert_eq!((c["lines"][2]["run"].as_u64(), c["lines"][2]["name"].as_str()), (Some(1), Some("t.js")));
assert!(!s.script_abandon() && s.script_pump().is_none(), "nothing is left to end");
assert_eq!(lines(&mut s).len(), 3, "the entry ends once");
s.script_frames = false;
run(&mut s, "$.writeln('two')").unwrap();
assert_eq!(texts(&mut s, "end").last().map(String::as_str), Some("ended: finished"), "the next run starts");
assert_eq!(lines(&mut s).last().map(|l| l.0), Some(2));
}
}
#[test]
fn runs_are_numbered_a_busy_run_adds_nothing_and_clear_keeps_the_report() {
let mut s = Session::new();
+247
View File
@@ -0,0 +1,247 @@
//! The `$` object's members besides `$.write` and `$.writeln`, the global `localize` and
//! `toSource`, and what of them one run keeps to itself.
use std::time::Duration;
use serde_json::{Value, json};
use crate::script::{TEST_ELAPSED, TEST_NO_CLOCK};
use crate::*;
/// The values `source` gives `vc.log`, in a run of its text.
fn logged(s: &mut Session, source: &str) -> Value {
s.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap_or_else(|e| panic!("{source}: {e}"))["log"].clone()
}
/// The one value `expr` gives, logged.
fn value(s: &mut Session, expr: &str) -> Value {
logged(s, &format!("vc.log({expr});"))[0].clone()
}
/// The name of the error `statement` throws and its message, as `Name: message`.
fn thrown(s: &mut Session, statement: &str) -> String {
value(s, &format!("(function () {{ try {{ {statement}; return 'no error'; }} catch (e) {{ return e.name + ': ' + e.message; }} }})()"))
.as_str()
.unwrap_or_default()
.to_string()
}
#[test]
fn each_member_has_its_value() {
let mut s = Session::new();
let version = env!("CARGO_PKG_VERSION");
let os = if cfg!(target_arch = "wasm32") {
"Web"
} else if cfg!(windows) {
"Windows"
} else if cfg!(target_os = "macos") {
"macOS"
} else if cfg!(target_os = "linux") {
"Linux"
} else if cfg!(target_os = "freebsd") {
"FreeBSD"
} else {
"Unix"
};
for (expr, want) in [
("$.about()", json!(format!("VectorCraft script engine {version}"))),
("$.appEncoding", json!("UTF-8")),
("$.build", json!(version)),
("$.buildDate", json!(null)),
("$.decimalPoint", json!(".")),
("$.engineName", json!("VectorCraft")),
("$.error", json!(null)),
("$.flags", json!(0)),
("$.gc() === undefined", json!(true)),
("$.global === this", json!(true)),
("typeof $.hiresTimer === 'number' && $.hiresTimer >= 0", json!(true)),
("$.level", json!(0)),
("\n$.line", json!(2)),
("$.locale", json!("en")),
("$.localize", json!(false)),
("$.memCache", json!(0)),
("$.os", json!(os)),
("$.screens.length", json!(0)),
("$.stack", json!("[global]:1\n")),
("$.strict", json!(false)),
("$.version", json!(version)),
("typeof $.getenv + typeof $.setenv + typeof $.sleep", json!("functionfunctionfunction")),
("vc.api.has('dollar')", json!(true)),
] {
assert_eq!(value(&mut s, expr), want, "{expr}");
}
assert_eq!(thrown(&mut s, "$.bp()"), "Error: $.bp: scripts here have no debugger");
assert_eq!(thrown(&mut s, "$.colorPicker(0)"), "Error: $.colorPicker: scripts cannot show a dialog yet");
}
#[test]
fn read_only_members_keep_their_values_and_settable_ones_check_what_they_are_given() {
let mut s = Session::new();
assert_eq!(value(&mut s, "($.build = 'x', $.level = 3, $.os = 'y', [$.build === 'x', $.level, $.os === 'y'])"), json!([false, 0, false]));
assert!(thrown(&mut s, "(function () { 'use strict'; $.version = '9'; })()").starts_with("TypeError"));
assert!(thrown(&mut s, "(function () { 'use strict'; $.error = null; })()").starts_with("TypeError"));
assert_eq!(value(&mut s, "($.appEncoding = 'utf-8', $.appEncoding)"), json!("UTF-8"));
assert!(thrown(&mut s, "$.appEncoding = 'ISO-8859-1'").starts_with("RangeError"));
assert!(thrown(&mut s, "$.appEncoding = 8").starts_with("TypeError"));
assert!(thrown(&mut s, "$.memCache = 1024").starts_with("Error"));
assert_eq!(value(&mut s, "($.flags = 512, [$.flags, $.localize])"), json!([512, true]));
assert_eq!(value(&mut s, "($.flags = 512, $.flags = 0, [$.flags, $.localize])"), json!([0, false]));
for bad in ["$.flags = 2", "$.flags = 513", "$.flags = -512"] {
assert!(thrown(&mut s, bad).starts_with("RangeError"), "{bad}");
}
assert!(thrown(&mut s, "$.flags = '512'").starts_with("TypeError"));
assert!(thrown(&mut s, "$.localize = 1").starts_with("TypeError"));
}
#[test]
fn the_locale_starts_as_the_interfaces_and_a_change_lasts_one_run() {
let mut s = Session::new();
for (ui, want) in [("de", "de"), ("pt-br", "pt_BR"), ("zh-hant", "zh_TW"), ("zh-hans", "zh_CN"), ("auto", "en"), ("x", "en")] {
s.ui_language = Some(ui.into());
assert_eq!(value(&mut s, "$.locale"), json!(want), "{ui}");
}
s.ui_language = None;
s.prefs.interface_language = "fr".into();
assert_eq!(value(&mut s, "$.locale"), json!("fr"), "without a language from the app, the preference");
s.ui_language = Some("de".into());
let lookup = "localize({en: 'e', de: 'd', fr: 'f', fr_CA: 'c'})";
assert_eq!(value(&mut s, &format!("[{lookup}, $.decimalPoint]")), json!(["d", ","]));
let changed = format!(
"$.locale = 'FR-ca'; var a = [$.locale, {lookup}]; $.locale = 'fr'; a.push({lookup}); $.locale = null; a.push($.locale, {lookup}); a"
);
assert_eq!(value(&mut s, &format!("(function () {{ {changed}; return a; }})()")), json!(["fr_CA", "c", "f", "de", "d"]));
assert_eq!(value(&mut s, "($.locale = '', $.locale)"), json!("de"), "an empty locale is the interface's");
assert_eq!(value(&mut s, "($.locale = 'en_US', $.locale)"), json!("en_US"));
assert_eq!(value(&mut s, "$.locale"), json!("de"), "the next run starts with the interface's again");
for bad in ["$.locale = 'german'", "$.locale = 'de_DE_x'", "$.locale = 'd'"] {
assert!(thrown(&mut s, bad).starts_with("RangeError"), "{bad}");
}
assert!(thrown(&mut s, "$.locale = 5").starts_with("TypeError"));
}
#[test]
fn localized_to_string_is_on_only_while_the_run_turns_it_on() {
let mut s = Session::new();
s.ui_language = Some("de".into());
let text = "String({en: 'Open', de: 'Öffnen'})";
assert_eq!(value(&mut s, text), json!("[object Object]"));
assert_eq!(value(&mut s, &format!("($.localize = true, {text})")), json!("Öffnen"));
assert_eq!(value(&mut s, text), json!("[object Object]"), "the next run starts with it off");
assert_eq!(value(&mut s, "localize('%1 of %2', 3, 7)"), json!("3 of 7"), "localize works with it off");
}
#[test]
fn to_source_writes_plain_data() {
let mut s = Session::new();
assert_eq!(value(&mut s, "toSource({a: [1, 'x'], b: null})"), json!(r#"({"a":[1, "x"], "b":null})"#));
assert_eq!(value(&mut s, "[true, 2].toSource()"), json!("[true, 2]"));
assert!(thrown(&mut s, "var o = {}; o.o = o; toSource(o)").starts_with("TypeError"));
assert!(thrown(&mut s, "toSource(vc)").starts_with("TypeError"), "a host object is not plain data");
}
#[test]
fn getenv_reads_only_its_names_and_setenv_changes_only_the_run() {
let mut s = Session::new();
for name in ["HOME", "PATH", "USERPROFILE", "TEMP", "lang", ""] {
assert_eq!(value(&mut s, &format!("$.getenv('{name}')")), json!(null), "{name}");
}
let process = std::env::var("OS").ok();
let before = value(&mut s, "$.getenv('OS')");
if let Some(os) = process.as_deref().filter(|v| v.len() <= 64 && v.bytes().all(|b| b.is_ascii_alphanumeric() || b"_.-@".contains(&b))) {
assert_eq!(before, json!(os));
}
assert_eq!(value(&mut s, "($.setenv('OS', 'Test-1'), $.getenv('OS'))"), json!("Test-1"));
assert_eq!(std::env::var("OS").ok(), process, "the process's environment stays as it was");
assert_eq!(value(&mut s, "$.getenv('OS')"), before, "the next run reads the process's value again");
assert!(thrown(&mut s, "$.setenv('HOME', 'x')").starts_with("Error"));
assert!(thrown(&mut s, "$.setenv('LANG', 'a b')").starts_with("RangeError"));
assert!(thrown(&mut s, &format!("$.setenv('LANG', '{}')", "a".repeat(65))).starts_with("RangeError"));
assert!(thrown(&mut s, "$.setenv('LANG')").starts_with("TypeError"));
assert!(thrown(&mut s, "$.getenv(1)").starts_with("TypeError"));
}
#[test]
fn error_line_and_stack_follow_the_running_code() {
let mut s = Session::new();
let source = "try { null.x; } catch (e) {}\nfunction where() {\n return [$.line, $.stack, $.error instanceof TypeError];\n}\nvc.log(where());";
assert_eq!(logged(&mut s, source)[0], json!([3, "[global]:5\nwhere:3\n", true]));
assert_eq!(value(&mut s, "$.error"), json!(null), "each run starts without one");
}
#[test]
fn sleep_waits_between_slices_until_its_time_has_passed() {
let mut s = Session::new();
s.script_frames = true;
TEST_ELAPSED.set(Some(Duration::ZERO));
s.execute("script.run", &json!({"source": "vc.log(1); $.sleep(250); vc.log(2);", "name": "t.js"})).unwrap();
let logs =
|s: &mut Session| s.execute("script.console", &json!({})).unwrap()["lines"].as_array().unwrap().iter().filter(|l| l["kind"] == "log").count();
assert!(s.script_pump().is_none());
assert_eq!(logs(&mut s), 1);
TEST_ELAPSED.set(Some(Duration::from_millis(249)));
assert!(s.script_pump().is_none());
assert!(s.script_pump().is_none());
assert_eq!(logs(&mut s), 1, "the run waits");
assert_eq!(s.script_sleep_left(), Some(Duration::from_millis(1)), "the app can wait this long before the next frame");
TEST_ELAPSED.set(Some(Duration::from_millis(250)));
let r = s.script_pump().unwrap().unwrap();
assert_eq!(r["log"], json!([1, 2]));
TEST_ELAPSED.set(None);
}
#[test]
fn a_sleeping_run_stops_when_asked() {
let mut s = Session::new();
s.script_frames = true;
TEST_ELAPSED.set(Some(Duration::ZERO));
s.execute("script.run", &json!({"source": "$.sleep(1000); vc.log(2);", "name": "t.js"})).unwrap();
assert!(s.script_pump().is_none());
assert_eq!(s.script_sleep_left(), Some(Duration::from_secs(1)));
s.execute("script.stop", &json!({})).unwrap();
assert_eq!(s.script_sleep_left(), None, "a run asked to stop does not wait");
assert!(s.script_pump().unwrap().is_err());
assert_eq!(s.script_report().unwrap()["kind"], "stopped");
TEST_ELAPSED.set(None);
}
#[test]
fn sleep_takes_milliseconds_from_0_to_1000() {
let mut s = Session::new();
assert_eq!(value(&mut s, "($.sleep(0), $.sleep(1), 'woke')"), json!("woke"));
for bad in ["$.sleep(-1)", "$.sleep(1001)"] {
assert!(thrown(&mut s, bad).starts_with("RangeError"), "{bad}");
}
for bad in ["$.sleep()", "$.sleep('5')", "$.sleep(NaN)", "$.sleep(Infinity)"] {
assert!(thrown(&mut s, bad).starts_with("TypeError"), "{bad}");
}
}
#[test]
fn sleep_throws_where_the_run_cannot_wait_instead_of_running_on() {
let mut s = Session::new();
s.script_frames = true;
TEST_ELAPSED.set(Some(Duration::ZERO));
let source = "var o = {}; Object.defineProperty(o, 'x', { get: function () { $.sleep(250); vc.log('after'); return 1; } });
try { o.x; } catch (e) { vc.log(e.name + ': ' + e.message); }
try { [1].map(function () { $.sleep(250); vc.log('after'); }); } catch (e) { vc.log(e.name + ': ' + e.message); }
[1].map(function () { $.sleep(0); });
vc.log('done');";
s.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap();
let r = s.script_pump().expect("the run does not wait").unwrap();
TEST_ELAPSED.set(None);
let message = "Error: $.sleep: a run cannot wait inside a callback of a built-in function, a getter, a conversion or a file $.evalFile runs; call it from the script's own code";
assert_eq!(r["log"], json!([message, message, "done"]));
}
#[test]
fn sleep_throws_where_the_run_cannot_wait_without_a_clock_too() {
let mut s = Session::new();
TEST_NO_CLOCK.set(true);
let source = "var o = {}; Object.defineProperty(o, 'x', { get: function () { $.sleep(250); return 1; } });
try { o.x; vc.log('no error'); } catch (e) { vc.log(e.name); }
$.sleep(250);
vc.log('done');";
let log = logged(&mut s, source);
TEST_NO_CLOCK.set(false);
assert_eq!(log, json!(["Error", "done"]), "the script's own code does not wait without a clock");
}
+180 -5
View File
@@ -194,7 +194,6 @@ fn script_target_other_app_does_not_route() {
("#target\nvc.log(1);", "#target needs a value"),
("#targetengine \"session\"", "unsupported script directive: #targetengine"),
("#engine \"main\"", "unsupported script directive: #engine"),
("#strict on", "unsupported strict checking: #strict on"),
("#strict maybe", "#strict takes on or off"),
("#frobnicate 1", "unknown script directive: #frobnicate"),
("#include \"x.jsxinc", "unterminated quote in #include"),
@@ -223,12 +222,134 @@ fn script_name_comes_from_the_script_line() {
let _ = std::fs::remove_dir_all(base);
}
/// The script's `ro`, with a read-only `x`.
const READ_ONLY: &str = "var ro = {};\nObject.defineProperty(ro, 'x', {value: 1});\n";
#[test]
fn script_strict_on_is_refused() {
fn strict_on_runs_and_read_only_write_throws() {
let mut s = Session::new();
let e = s.execute("script.run", &json!({"source": "#strict on\nvc.log(1);"})).unwrap_err().to_string();
assert!(e.contains("unsupported strict checking: #strict on"), "{e}");
assert_eq!(s.script_report().unwrap()["kind"], "syntax");
for head in ["#strict on\n", "//@strict on\n", "#strict ON\n", "#strict off\n#strict on\n"] {
let source = format!("{head}{READ_ONLY}vc.log(1);\n ro.x = 2;\nvc.log(2);");
let e = s.execute("script.run", &json!({"source": source})).unwrap_err().to_string();
assert!(e.contains("TypeError: cannot assign to property 'x': it is read-only"), "{head}: {e}");
let report = s.script_report().unwrap();
assert_eq!(report["kind"], "thrown", "{head}");
assert_eq!(report["log"], json!([1]), "{head}: the script ran up to the write");
let line = head.lines().count() as u64 + 4;
assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(line), Some(3)), "{head}: {report}");
}
// The error can be caught, and every kind of assignment is checked.
let source = format!(
"#strict on\n{READ_ONLY}var out = [];\nfunction t(f) {{ try {{ f(); out.push('wrote'); }} catch (e) {{ out.push(e.name); }} }}\nt(function () {{ ro.x = 2; }});\nt(function () {{ ro['x'] += 1; }});\nt(function () {{ ro.x++; }});\nt(function () {{ undefined = 1; }});\nt(function () {{ [].concat.length = 0; }});\nvc.log(out);\nvc.log(ro.x);"
);
let r = s.execute("script.run", &json!({"source": source})).unwrap();
assert_eq!(r["log"], json!([["TypeError", "TypeError", "TypeError", "TypeError", "TypeError"], 1]));
// Without the line, or with `#strict off` last, the write is ignored as before.
for head in ["", "#strict off\n", "#strict on\n#strict off\n"] {
let r = s.execute("script.run", &json!({"source": format!("{head}{READ_ONLY}ro.x = 2;\nvc.log(ro.x);")})).unwrap();
assert_eq!(r["log"], json!([1]), "{head}");
}
}
#[test]
fn strict_on_rejects_getter_only_and_nonextensible_additions() {
let mut s = Session::new();
let source = "#strict on\nvar out = [];\nfunction t(f) { try { f(); out.push('wrote'); } catch (e) { out.push(String(e)); } }\n\
var g = {get v() { return 1; }}, set = {set v(x) { this.got = x; }};\n\
t(function () { g.v = 2; });\n\
t(function () { Object.preventExtensions({}).n = 1; });\n\
t(function () { Object.seal({a: 1}).n = 1; });\n\
t(function () { Object.freeze({a: 1}).a = 2; });\n\
t(function () { vc.extra = 1; });\n\
var open = {};\nt(function () { open.n = 1; });\nt(function () { set.v = 3; });\nt(function () { Object.seal({a: 1}).a = 2; });\n\
vc.log(out);\nvc.log(open.n);\nvc.log(set.got);";
let r = s.execute("script.run", &json!({"source": source})).unwrap();
assert_eq!(
r["log"],
json!([
[
"TypeError: cannot assign to property 'v': it has only a getter",
"TypeError: cannot assign to property 'n': the object is not extensible",
"TypeError: cannot assign to property 'n': the object is not extensible",
"TypeError: cannot assign to property 'a': it is read-only",
"TypeError: cannot assign to property 'extra': the object is not extensible",
"wrote",
"wrote",
"wrote"
],
1,
3
])
);
let r = s.execute("script.run", &json!({"source": source.replacen("#strict on", "#strict off", 1)})).unwrap();
assert_eq!(r["log"][0], json!(["wrote", "wrote", "wrote", "wrote", "wrote", "wrote", "wrote", "wrote"]), "off, nothing is refused");
}
#[test]
fn strict_on_does_not_enable_es5_early_errors() {
let mut s = Session::new();
let source = "#strict on\nwith ({w: 1}) { vc.log(w); }\nfunction f(a, a) { return a; }\nvc.log(f(1, 2));\nvc.log(010);\nundeclared = 4;\nvc.log(undeclared);\n\
vc.log((function () { return typeof this; })());\nvc.log(delete Object.prototype);\nvar eval2 = 5;\nvc.log((function () { arguments = 6; return arguments; })());";
let r = s.execute("script.run", &json!({"source": source})).unwrap();
assert_eq!(r["log"], json!([1, 2, 8, 4, "object", false, 6]));
// 'use strict' still makes the code strict mode code.
let e = s.execute("script.run", &json!({"source": "#strict on\n'use strict';\nundeclared2 = 1;"})).unwrap_err().to_string();
assert!(e.contains("ReferenceError"), "{e}");
let e = s.execute("script.run", &json!({"source": "#strict on\n'use strict';\nwith ({}) {}"})).unwrap_err().to_string();
assert_eq!(s.script_report().unwrap()["kind"], "syntax", "{e}");
}
#[test]
fn strict_on_in_include_controls_expanded_run() {
let base = folder("strict");
write(&base.join("main/on.jsxinc"), "#strict on\nvar inc = 1;");
write(&base.join("main/off.jsxinc"), "//@strict off\n");
write(&base.join("main/writes.jsxinc"), &format!("{READ_ONLY}\n ro.x = 2;\n"));
// An included file's line sets the checks for the script's own code too.
let report = failed(&base, &format!("#include \"on.jsxinc\"\n{READ_ONLY}ro.x = 2;"));
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
assert_eq!(report["line"], 4, "{report}");
assert!(report.get("file").is_none(), "{report}");
// A write in an included file is reported at its own line.
let report = failed(&base, "#strict on\n#include \"writes.jsxinc\"");
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(4), Some(3)), "{report}");
assert!(report["file"].as_str().unwrap().ends_with("writes.jsxinc"), "{report}");
// The last line of the expanded run wins, wherever it is.
let r = run(&base, &format!("#strict on\n#include \"off.jsxinc\"\n{READ_ONLY}ro.x = 2;\nvc.log(ro.x);")).unwrap();
assert_eq!(r["log"], json!([1]));
let report = failed(&base, &format!("#include \"off.jsxinc\"\n#strict on\n{READ_ONLY}ro.x = 2;"));
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn strict_mode_in_eval_file_is_scoped() {
let base = folder("strict-eval");
write(
&base.join("main/on.jsx"),
&format!(
"#strict on\n{READ_ONLY}function w(o) {{ o.x = 2; }}\nvar inner;\ntry {{ ro.x = 2; inner = 'wrote'; }} catch (e) {{ inner = e.name; }}\n"
),
);
write(&base.join("main/off.jsx"), &format!("#strict off\n{READ_ONLY}ro.x = 2;\n'off ran';"));
write(&base.join("main/plain.jsx"), &format!("{READ_ONLY}ro.x = 2;\n'plain ran';"));
// A file's own `#strict on` holds for its code, its functions included, and not the caller's.
let r = run(
&base,
"$.evalFile('on.jsx');\nvar after;\ntry { ro.x = 3; after = 'ignored'; } catch (e) { after = e.name; }\nvar called;\ntry { w(ro); called = 'wrote'; } catch (e) { called = e.name; }\nvc.log([inner, after, called]);",
)
.unwrap();
assert_eq!(r["log"], json!([["TypeError", "ignored", "TypeError"]]));
// A file without the line takes the caller's checks, and its own `#strict off` turns them off.
let r = run(&base, "#strict on\nvc.log($.evalFile('off.jsx'));\nvar e1;\ntry { ro.x = 2; } catch (e) { e1 = e.name; }\nvc.log(e1);").unwrap();
assert_eq!(r["log"], json!(["off ran", "TypeError"]), "the caller keeps its own checks");
let report = failed(&base, "#strict on\n$.evalFile('plain.jsx');");
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
assert!(report["file"].as_str().unwrap().ends_with("plain.jsx"), "{report}");
assert_eq!(report["line"], 3, "{report}");
assert_eq!(run(&base, "vc.log($.evalFile('plain.jsx'));").unwrap()["log"], json!(["plain ran"]));
let _ = std::fs::remove_dir_all(base);
}
#[test]
@@ -388,3 +509,57 @@ fn script_dialogs_in_included_and_evaluated_files() {
assert_eq!(report["dialogs"][0]["answer"], true, "{report}");
let _ = std::fs::remove_dir_all(base);
}
/// The line, column and file name of a failed run's report, and its console error line (the only
/// line of a run without output that starts with the script's name).
fn place(report: &Value) -> (u64, u64, Option<&str>, &str) {
let file = report.get("file").map(|f| f.as_str().unwrap().rsplit(['/', '\\']).next().unwrap());
let lines = report["console"]["lines"].as_array().unwrap();
let error = lines.iter().filter_map(Value::as_str).find(|l| l.starts_with("main.jsx")).unwrap();
(report["line"].as_u64().unwrap(), report["column"].as_u64().unwrap(), file, error)
}
#[test]
fn script_eval_file_error_caught_and_thrown_again_is_at_the_new_throw() {
let base = folder("rethrow");
write(&base.join("main/sub/broken.jsx"), "var a = 1;\n\nvar = 2;");
write(&base.join("main/sub/directive.jsx"), "\n#frobnicate 1");
write(&base.join("main/sub/rethrow.jsx"), "var r = 1;\n throw saved;");
write(&base.join("main/sub/outer.jsx"), "var q = 1;\n$.evalFile('broken.jsx');");
write(&base.join("main/sub/outer2.jsx"), "try { $.evalFile('broken.jsx'); } catch (e) {\n\n throw e;\n}");
let caught = "var saved;\ntry { $.evalFile('sub/broken.jsx'); } catch (e) { saved = e; }\n";
let cases = [
// Thrown again in the script, or in another file $.evalFile runs.
(format!("{caught}var b = 2;\n throw saved;"), (4, 3, None)),
(format!("{caught}$.evalFile('sub/rethrow.jsx');"), (2, 3, Some("rethrow.jsx"))),
// A new error whose message holds the failure's text, or is the same text.
(format!("{caught}throw new Error(saved.message);"), (3, 1, None)),
(format!("{caught}throw new SyntaxError(saved.message);"), (3, 1, None)),
// A second failure, caught, does not move the first one.
(format!("{caught}try {{ $.evalFile('sub/directive.jsx'); }} catch (e) {{}}\n throw saved;"), (4, 2, None)),
// A second failure, uncaught, is at its own file's place.
(format!("{caught}$.evalFile('sub/directive.jsx');"), (2, 1, Some("directive.jsx"))),
// An uncaught failure is at the file's place, through a finally block and from a nested file.
("var a = 1;\ntry { $.evalFile('sub/broken.jsx'); } finally { a = 2; }".to_string(), (3, 5, Some("broken.jsx"))),
("$.evalFile('sub/outer.jsx');".to_string(), (3, 5, Some("broken.jsx"))),
// Caught in a nested file and thrown again there.
("$.evalFile('sub/outer2.jsx');".to_string(), (3, 5, Some("outer2.jsx"))),
// A failure caught in a run after one that failed with it, and a different error.
("try { $.evalFile('sub/broken.jsx'); } catch (e) {}\nthrow new Error('other');".to_string(), (2, 1, None)),
];
let mut s = Session::new();
let main = base.join("main/main.jsx");
for (text, (line, column, file)) in cases {
write(&main, &text);
assert!(s.execute("script.run", &json!({"path": main.to_string_lossy()})).is_err(), "{text}");
let report = s.script_report().unwrap().clone();
let (l, c, f, error) = place(&report);
assert_eq!((report["kind"].as_str(), l, c, f), (Some("thrown"), line, column, file), "{text}\n{report}");
let at = match file {
Some(f) => format!("{f}:{line}:{column}: "),
None => format!("main.jsx:{line}:{column}: "),
};
assert!(error.starts_with("main.jsx") && error.contains(&at), "{error}");
}
let _ = std::fs::remove_dir_all(base);
}
+105
View File
@@ -259,6 +259,111 @@ fn pasted_art_keeps_a_filter_another_of_its_name_would_replace() {
assert_eq!(both, [("Shift".to_string(), SHIFT_MORE.to_string()), ("Shift".to_string(), SHIFT_MORE.to_string())]);
}
/// A pasted filter at the size limit, which "Shift 2" would make too long to draw, keeps its own
/// definition under a free name no longer than its own (a free letter for a one-letter name), and
/// its id is that name.
#[test]
fn pasted_art_with_a_filter_at_the_size_limit_still_draws_when_renamed() {
for (name, held, renamed) in [("Shift", SHIFT, "Shif"), ("S", r#"<filter id="S"><feOffset dx="3" dy="3"/></filter>"#, "a")] {
let mut s = session();
run(&mut s, "svgFilter.define", json!({"xml": held}));
let (open, close) = (format!(r#"<filter id="{name}"><feOffset dx="9" dy="9" result=""#), r#""/></filter>"#);
let big = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len()));
assert!(vectorcraft_render::effects::svg_filter::check(&big).is_ok());
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs>{big}</defs><rect width="30" height="30" filter="url(#{name})"/></svg>"#
);
run(&mut s, "clipboard.importSvg", json!({"svg": svg}));
run(&mut s, "edit.pasteInPlace", json!({}));
let pasted = filter_effects(&s);
assert_eq!(pasted.len(), 1);
assert_eq!(pasted[0].0, renamed, "renamed: {}", pasted[0].0);
assert_eq!(vectorcraft_render::effects::svg_filter::check(&pasted[0].1), Ok(renamed.to_string()), "the pasted filter draws under its name");
assert!(pasted[0].1.contains("dx=\"9\""), "its own filter");
assert_eq!(names(&mut s), [name]);
}
}
/// A pasted filter is not renamed to a name an effect in the document already gives, even one whose
/// filter was deleted, so defining a filter of that name later changes only that effect.
#[test]
fn pasted_art_takes_no_name_the_documents_effects_use() {
let mut s = session();
let a = rect(&mut s, 10.0);
run(&mut s, "svgFilter.define", json!({"xml": r#"<filter id="Shift 2"><feOffset dx="1" dy="1"/></filter>"#}));
run(&mut s, "svgFilter.apply", json!({"name": "Shift 2", "ids": [a.0]}));
run(&mut s, "svgFilter.delete", json!({"name": "Shift 2"}));
run(&mut s, "svgFilter.define", json!({"xml": SHIFT}));
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs>{SHIFT_MORE}</defs><rect width="30" height="30" filter="url(#Shift)"/></svg>"#
);
run(&mut s, "clipboard.importSvg", json!({"svg": svg}));
run(&mut s, "edit.pasteInPlace", json!({}));
let names: Vec<String> = filter_effects(&s).into_iter().map(|(n, _)| n).collect();
assert_eq!(names, ["Shift 2", "Shift 3"]);
}
/// A pasted filter is not renamed to a name an effect in a brush's art gives either.
#[test]
fn pasted_art_takes_no_name_the_documents_brushes_use() {
let mut s = session();
let a = rect(&mut s, 10.0);
run(&mut s, "svgFilter.define", json!({"xml": r#"<filter id="Shift 2"><feOffset dx="1" dy="1"/></filter>"#}));
run(&mut s, "svgFilter.apply", json!({"name": "Shift 2", "ids": [a.0]}));
run(&mut s, "select.set", json!({"ids": [a.0]}));
run(&mut s, "brush.new", json!({"type": "art", "name": "Fx"}));
run(&mut s, "edit.clear", json!({"ids": [a.0]}));
run(&mut s, "svgFilter.delete", json!({"name": "Shift 2"}));
run(&mut s, "svgFilter.define", json!({"xml": SHIFT}));
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs>{SHIFT_MORE}</defs><rect width="30" height="30" filter="url(#Shift)"/></svg>"#
);
run(&mut s, "clipboard.importSvg", json!({"svg": svg}));
run(&mut s, "edit.pasteInPlace", json!({}));
let names: Vec<String> = filter_effects(&s).into_iter().map(|(n, _)| n).collect();
assert_eq!(names, ["Shift 3"]);
}
/// A pasted filter at the size limit with a one-letter name, when every free name it could take is
/// in use, is refused rather than pasted under the name of the document's filter.
#[test]
fn a_paste_with_no_free_name_for_its_filter_is_refused() {
let mut s = session();
for n in ('a'..='z').chain('0'..='9') {
run(&mut s, "svgFilter.define", json!({"xml": format!(r#"<filter id="{n}"><feOffset dx="1" dy="1"/></filter>"#)}));
}
run(&mut s, "svgFilter.define", json!({"xml": r#"<filter id="S"><feOffset dx="3" dy="3"/></filter>"#}));
let (open, close) = (r#"<filter id="S"><feOffset dx="9" dy="9" result=""#, r#""/></filter>"#);
let big = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len()));
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs>{big}</defs><rect width="30" height="30" filter="url(#S)"/></svg>"#
);
run(&mut s, "clipboard.importSvg", json!({"svg": svg}));
let before = steps(&s);
assert!(err(&mut s, "edit.pasteInPlace", json!({})).contains("no free name"));
assert!(filter_effects(&s).is_empty(), "nothing pasted");
assert_eq!(steps(&s), before);
}
/// A pasted filter whose name holds a tab, a line feed or a carriage return keeps those characters
/// in its id under its new name.
#[test]
fn a_renamed_paste_keeps_whitespace_characters_in_its_id() {
for c in ["&#9;", "&#10;", "&#13;"] {
let mut s = session();
run(&mut s, "svgFilter.define", json!({"xml": format!(r#"<filter id="S{c}t"><feOffset dx="3" dy="3"/></filter>"#)}));
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs><filter id="S{c}t"><feOffset dx="9" dy="9"/></filter></defs><rect width="30" height="30" filter="url(#S{c}t)"/></svg>"#
);
run(&mut s, "clipboard.importSvg", json!({"svg": svg}));
run(&mut s, "edit.pasteInPlace", json!({}));
let pasted = filter_effects(&s);
assert_eq!(pasted.len(), 1);
assert_ne!(pasted[0].0, names(&mut s)[0], "renamed");
assert_eq!(vectorcraft_render::effects::svg_filter::check(&pasted[0].1), Ok(pasted[0].0.clone()), "{c}: its id is its name");
}
}
/// Art in a symbol's definition that uses a filter follows its edits too.
#[test]
fn a_symbols_art_follows_a_filter_edit() {
+1
View File
@@ -522,6 +522,7 @@ fn character_ruby_and_path_handle_refuse_a_param_of_the_wrong_type() {
("text.setStyle", json!({"size": 30, "justify": {"value": "center"}}), string("justify")),
("text.setStyle", json!({"features": ["dlig", 5]}), tags.clone()),
("text.setStyle", json!({"leading": true}), "leading must be a number or \"auto\"".to_string()),
("text.setStyle", json!({"leading": "12abc"}), "leading must be a number or \"auto\"".to_string()),
("text.setRangeStyle", json!({"id": id.0, "font": 5}), string("font")),
("text.setRangeStyle", json!({"id": id.0, "size": 30, "style": false}), string("style")),
("text.setRangeStyle", json!({"id": id.0, "features": [null]}), tags),
+52 -15
View File
@@ -9,10 +9,11 @@ use vectorcraft_engine::{Session, command_specs};
use vectorcraft_testkit::catch_quiet;
use vectorcraft_testkit::fixtures::Fixture;
use vectorcraft_testkit::invariants::{check_all, check_session, doc_json};
use vectorcraft_testkit::strategies::{junk_params, junk_values, param_keys};
use vectorcraft_testkit::strategies::{junk_params, junk_values, param_keys, safe_value};
/// Commands that replace/close the active document or touch the filesystem are fine to call, but a
/// few need their paths redirected into a temp dir (handled by `junk_params`).
/// few need their file and folder params redirected into a temp dir (`safe_value`, which
/// `junk_params` applies).
fn safe_path() -> String {
vectorcraft_testkit::temp_dir("sweep").join("out.bin").to_string_lossy().to_string()
}
@@ -230,6 +231,54 @@ fn tool_edit_state(s: &Session) -> ToolEditState {
(docs, s.active_index(), s.paint.clone())
}
/// The params the tool-edit sweep sends `c`: junk params, then each documented key turned on and
/// off, with a string under a file or folder key replaced by a location inside `path`'s folder.
fn tool_edit_cases(c: &vectorcraft_engine::CommandSpec, path: &str) -> Vec<Value> {
let mut cases = junk_params(c.params, path);
if heavy(c.id) {
cases.truncate(12);
}
for k in param_keys(c.params) {
for v in [json!(true), json!(false), json!(1), json!("on")] {
cases.push(json!({ k.as_str(): safe_value(&k, &v, path) }));
}
}
// Find Objects selects only with a filter, which one key at a time does not give it.
if c.id == "document.find" {
cases.extend([json!({"kind": "Path", "select": true}), json!({"name": "no such name", "select": true})]);
}
cases
}
/// Every string the sweeps send under a key that names a file or folder to write is inside the
/// sweep's temp folder, so no run writes into the working directory.
#[test]
fn sweep_cases_name_files_only_inside_the_temp_folder() {
let path = safe_path();
let dir = vectorcraft_testkit::temp_dir("sweep");
let keys = ["path", "diffPath", "folder", "destinationFolder"];
let mut seen = std::collections::BTreeSet::new();
let mut outside = std::collections::BTreeSet::new();
for c in command_specs() {
let mut cases = junk_params(c.params, &path);
cases.extend(tool_edit_cases(c, &path));
for p in &cases {
let Some(o) = p.as_object() else { continue };
for (k, v) in o.iter().filter(|(k, _)| keys.contains(&k.as_str())) {
seen.insert(k.clone());
if let Some(s) = v.as_str()
&& !std::path::Path::new(s).starts_with(&dir)
{
outside.insert(format!("{} {{\"{k}\": {v}}}", c.id));
}
}
}
}
assert_eq!(seen.len(), keys.len(), "the sweeps send only {seen:?}");
let outside: Vec<String> = outside.into_iter().collect();
assert!(outside.is_empty(), "{} cases name a location outside {}:\n{}", outside.len(), dir.display(), outside.join("\n"));
}
/// Every command that runs at once while the active tool computes an edit on another thread (a
/// Shape Builder merge), rather than applying the edit first (`waits_for_tool_edit`), leaves the
/// documents' revisions and selections, the active document and the current paint as they were,
@@ -241,19 +290,7 @@ fn commands_that_do_not_wait_for_a_tool_edit_change_no_document() {
let mut failures = vec![];
for fx in [Fixture::Single, Fixture::Multi] {
for c in command_specs().iter().filter(|c| !vectorcraft_engine::waits_for_tool_edit(c.id)) {
let mut cases = junk_params(c.params, &path);
if heavy(c.id) {
cases.truncate(12);
}
for k in param_keys(c.params) {
for v in [json!(true), json!(false), json!(1), json!("on")] {
cases.push(json!({ k.as_str(): v }));
}
}
// Find Objects selects only with a filter, which one key at a time does not give it.
if c.id == "document.find" {
cases.extend([json!({"kind": "Path", "select": true}), json!({"name": "no such name", "select": true})]);
}
let cases = tool_edit_cases(c, &path);
for p in &cases {
let mut s = fx.session();
let before = tool_edit_state(&s);
+68 -1
View File
@@ -525,6 +525,51 @@ fn many_objects_of_one_name_are_numbered_in_turn() {
assert!(text.contains("(Dot_00000000000000019999_)"));
}
/// A document with one top-level layer for each of the 27 layer colour presets, named after its
/// position.
fn every_layer_preset() -> Document {
let mut doc = Document::new(100.0, 100.0);
doc.layers.clear();
for i in 0..vectorcraft_doc::LAYER_COLORS.len() {
let id = doc.alloc_id();
doc.layers.push(Arc::new(Node::layer(id, &format!("P{i}"), LayerColor::Preset(u8::try_from(i).unwrap()))));
}
doc
}
/// #6502: Yellow, Magenta and Cyan (presets 4, 5 and 6) are written as the file's indexes 6, 4
/// and 5, with their own RGB beside them; every other preset keeps its position.
#[test]
fn layer_presets_are_written_with_the_files_colour_index() {
let text = program(&every_layer_preset());
for (i, (_, [r, g, b])) in vectorcraft_doc::LAYER_COLORS.iter().enumerate() {
let index = match i {
4 => 6,
5 => 4,
6 => 5,
i => i,
};
let want = format!(" {index} {r} {g} {b} 0 50 0 Lb\n(P{i}) Ln\n");
assert!(text.contains(&want), "missing {want:?}");
}
}
/// #6502: every preset comes back as itself after a save and an open.
#[test]
fn layer_presets_survive_a_round_trip() {
let doc = read_back(&every_layer_preset());
let colors: Vec<LayerColor> = doc
.layers
.iter()
.map(|l| match l.kind {
NodeKind::Layer { color, .. } => color,
_ => panic!("{:?}", l.kind),
})
.collect();
let want: Vec<LayerColor> = (0..27u8).map(LayerColor::Preset).collect();
assert_eq!(colors, want);
}
#[test]
fn layers_have_fourteen_operands() {
let text = program(&sample());
@@ -3449,6 +3494,9 @@ fn typed() -> Document {
v_scale: 90.0,
stroke: solid(Color::rgb(0.0, 0.5, 0.0)),
stroke_width: 0.5,
stroke_cap: vectorcraft_doc::LineCap::Square,
stroke_join: vectorcraft_doc::LineJoin::Bevel,
stroke_miter_limit: 2.5,
all_caps: true,
..regular.clone()
};
@@ -3565,7 +3613,7 @@ fn type_lines(doc: &Document) -> Vec<String> {
for r in &t.runs {
let s = &r.style;
out.push(format!(
" run {:?} {} {} {} {:?} {} {} {} {} fill {} stroke {} {} caps={} small={:?} tcy={} roman={}",
" run {:?} {} {} {} {:?} {} {} {} {} fill {} stroke {} {} {:?} {:?} {} caps={} small={:?} tcy={} roman={}",
r.text,
s.font_family,
s.font_style,
@@ -3578,6 +3626,9 @@ fn type_lines(doc: &Document) -> Vec<String> {
paint(&s.fill),
paint(&s.stroke),
r3(s.stroke_width),
s.stroke_cap,
s.stroke_join,
r3(s.stroke_miter_limit),
s.all_caps,
s.small_caps.map(r3),
s.tate_chu_yoko,
@@ -3718,6 +3769,22 @@ fn type_comes_back_as_type() {
assert_eq!(back.text_threads.len(), 1, "the thread comes back");
}
/// A run without a stroke whose stroke's cap, join and miter limit aren't the defaults loses
/// nothing that draws: the save reports no attributes left out.
#[test]
fn an_unstroked_run_with_a_stroke_style_loses_nothing() {
use vectorcraft_doc::{CharStyle, LineCap, LineJoin, TextObject};
for miter in [3.0, f64::NAN] {
let st = CharStyle { stroke_cap: LineCap::Round, stroke_join: LineJoin::Bevel, stroke_miter_limit: miter, ..style(14.0, Color::BLACK) };
let mut doc = Document::new(200.0, 200.0);
let layer = doc.layers[0].id;
let n = text_node(&mut doc, TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), "Plain", st));
doc.insert(Some(layer), usize::MAX, n).unwrap();
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.is_empty(), "{miter}: {:?}", out.warnings);
}
}
#[test]
fn type_is_written_again_as_the_same_bytes() {
let doc = typed();
+2 -9
View File
@@ -7,7 +7,7 @@ use std::sync::Arc;
use vectorcraft_color::cms::Cms;
use vectorcraft_color::swatch::{REGISTRATION, Swatch};
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Knockout, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, OpacityMask, StrokeLayer};
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Knockout, LayerColor, Node, NodeId, NodeKind, OpacityMask, StrokeLayer};
use vectorcraft_effects::stroke;
use vectorcraft_geom::{Affine, FillRule, PathData, Shape, SubPath};
use vectorcraft_pathops::BoolOp;
@@ -449,14 +449,7 @@ impl<'a> Writer<'a> {
/// (`Xy`, `0 0 Xd`, `7 () XW`) when it has any, `LB`, `%AI5_EndLayer--`.
fn write_layer(&mut self, n: &Node, after: &[Arc<Node>]) {
let NodeKind::Layer { color, template, printable, children, clip, preview, dim_images } = &n.kind else { return };
let [r, g, b] = match color {
LayerColor::Preset(i) => LAYER_COLORS.get(usize::from(*i)).map_or([79, 128, 255], |c| c.1),
LayerColor::Custom(rgb) => *rgb,
};
let index = match color {
LayerColor::Preset(i) if usize::from(*i) < LAYER_COLORS.len() => i64::from(*i),
_ => -1,
};
let (index, [r, g, b]) = ai_ops::layer_color(*color);
if *template {
self.warn(TEMPLATES);
}
+51 -5
View File
@@ -21,15 +21,15 @@ use std::fmt::Write;
use kurbo::{ParamCurveArclen, PathSeg};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{
AreaOptions, Burasagari, CharStyle, Document, FirstBaseline, Justify, Mojikumi, Node, NodeId, NodeKind, ParaDirection, ParaStyle, PathAlign,
PathEffect, TextKind, TextObject, VerticalAlign,
AreaOptions, Burasagari, CharStyle, Document, FirstBaseline, Justify, LineCap, LineJoin, Mojikumi, Node, NodeId, NodeKind, ParaDirection,
ParaStyle, PathAlign, PathEffect, TextKind, TextObject, VerticalAlign,
};
use vectorcraft_geom::{Affine, PathData, Point};
use crate::ps;
/// What the text document holds of type, and what it leaves out.
pub(in crate::ai_editing) const TYPE_ATTRIBUTES: &str = "attributes of type other than its font, size, leading, tracking, baseline shift, horizontal and vertical scale, solid fill, solid stroke and stroke weight, capitals, small capitals at the document's size, Tate-chu-yoko, Standard Vertical Roman Alignment, paragraph alignment, indents, space before and after, hyphenation on or off and right-to-left direction (for example underline, kerning, OpenType features, swatch links of its colors, character and paragraph styles, tabs, lists, optical margin alignment, area type's inset spacing, vertical alignment and flow, a minimum first baseline with the Ascent first baseline, and type on a path's effect, alignment and spacing and an end bracket at the very end of an open path) are left out of the editing data for other apps";
pub(in crate::ai_editing) const TYPE_ATTRIBUTES: &str = "attributes of type other than its font, size, leading, tracking, baseline shift, horizontal and vertical scale, solid fill, solid stroke and its weight, cap, join and miter limit, capitals, small capitals at the document's size, Tate-chu-yoko, Standard Vertical Roman Alignment, paragraph alignment, indents, space before and after, hyphenation on or off and right-to-left direction (for example underline, kerning, OpenType features, swatch links of its colors, character and paragraph styles, tabs, lists, optical margin alignment, area type's inset spacing, vertical alignment and flow, a minimum first baseline with the Ascent first baseline, and type on a path's effect, alignment and spacing and an end bracket at the very end of an open path) are left out of the editing data for other apps";
/// The version of the text document's form (`/98 << /0 n >>`), as the newest files users sent have it.
const STREAM_VERSION: u32 = 14;
@@ -347,6 +347,9 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool {
fill: unlinked(&s.fill),
stroke: unlinked(&s.stroke),
stroke_width: if stroked { s.stroke_width } else { CharStyle::default().stroke_width },
stroke_cap: if stroked { s.stroke_cap } else { CharStyle::default().stroke_cap },
stroke_join: if stroked { s.stroke_join } else { CharStyle::default().stroke_join },
stroke_miter_limit: if stroked { miter_limit(s.stroke_miter_limit) } else { CharStyle::default().stroke_miter_limit },
all_caps: s.all_caps,
small_caps: s.small_caps.filter(|v| *v == default_small_caps),
tate_chu_yoko: s.tate_chu_yoko,
@@ -357,7 +360,19 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool {
};
let run_lost = |s: &CharStyle| {
let stroked = !s.stroke.is_none();
let s = CharStyle { stroke_width: if stroked { s.stroke_width } else { CharStyle::default().stroke_width }, ..s.clone() };
// Without a stroke, its weight, cap, join and miter limit draw nothing: nothing is lost.
let unstroked = CharStyle::default();
let s = if stroked {
s.clone()
} else {
CharStyle {
stroke_width: unstroked.stroke_width,
stroke_cap: unstroked.stroke_cap,
stroke_join: unstroked.stroke_join,
stroke_miter_limit: unstroked.stroke_miter_limit,
..s.clone()
}
};
// A Lab color is written as RGB.
let lab = |p: &Paint| matches!(p, Paint::Solid { color: Color::Lab { .. }, .. });
s != held_char(&s) || lab(&s.fill) || lab(&s.stroke) || !font_kept(&s)
@@ -463,6 +478,32 @@ fn font_name(st: &CharStyle) -> FontRecord {
}
}
/// The text document's number for a stroke's cap (key 61), as PostScript numbers them: 0 butt, 1
/// round, 2 projecting.
fn cap_code(cap: LineCap) -> i64 {
match cap {
LineCap::Butt => 0,
LineCap::Round => 1,
LineCap::Square => 2,
}
}
/// The text document's number for a stroke's join (key 62), as PostScript numbers them: 0 miter, 1
/// round, 2 bevel.
fn join_code(join: LineJoin) -> i64 {
match join {
LineJoin::Miter => 0,
LineJoin::Round => 1,
LineJoin::Bevel => 2,
}
}
/// A stroke's miter limit (key 64) as the reader takes it back: at least 1 and at most 500, 10 when
/// it isn't a number at least 1.
fn miter_limit(v: f64) -> f64 {
if v.is_finite() && v >= 1.0 { v.min(500.0) } else { 10.0 }
}
/// `v` as the 16.16 fixed-point number a design vector holds (`wght` 700 is 45875200).
fn fixed(v: f32) -> i64 {
if v.is_finite() { (f64::from(v) * 65536.0).round() as i64 } else { 0 }
@@ -621,7 +662,12 @@ impl TextDoc {
}
d.extend([(56, V::Bool(fill.is_some())), (57, V::Bool(stroke.is_some()))]);
if stroke.is_some() {
d.push((63, V::Small(st.stroke_width / scale)));
d.extend([
(61, V::Int(cap_code(st.stroke_cap))),
(62, V::Int(join_code(st.stroke_join))),
(63, V::Small(st.stroke_width / scale)),
(64, real(miter_limit(st.stroke_miter_limit))),
]);
}
dict(d)
}
+38 -1
View File
@@ -6,7 +6,7 @@
use std::fmt::Write;
use vectorcraft_color::{Color, Gradient};
use vectorcraft_doc::{LineCap, LineJoin, StrokeLayer};
use vectorcraft_doc::{LAYER_COLORS, LayerColor, LineCap, LineJoin, StrokeLayer};
use vectorcraft_geom::{Affine, Anchor, AnchorKind, PathData, Point, SubPath};
use crate::ps;
@@ -62,6 +62,43 @@ pub(crate) fn guide(s: &mut String, xf: Affine, path: &PathData, editing: bool)
}
}
/// The colour index an `.ai` layer's `Lb` gives preset `preset` (its position in
/// [`LAYER_COLORS`]). The file numbers Magenta, Cyan and Yellow 4, 5 and 6, where
/// [`LAYER_COLORS`] has Yellow, Magenta and Cyan; every other preset has its own position (#6502,
/// from the index and RGB pairs of the `Lb` records in files users sent). A reader takes the index
/// and ignores the RGB beside it, so the index decides the colour.
pub(crate) fn ai_layer_index(preset: u8) -> u8 {
match preset {
4 => 6,
5 => 4,
6 => 5,
i => i,
}
}
/// The preset (position in [`LAYER_COLORS`]) of the colour index `index` in an `.ai` layer's
/// `Lb`: the inverse of [`ai_layer_index`].
pub(crate) fn preset_of_ai_layer_index(index: u8) -> u8 {
match index {
6 => 4,
4 => 5,
5 => 6,
i => i,
}
}
/// The colour index and RGB an `.ai` layer's `Lb` writes for `color`: a preset's index
/// ([`ai_layer_index`]) with its RGB, or `-1` with a custom colour's RGB.
pub(crate) fn layer_color(color: LayerColor) -> (i64, [u8; 3]) {
match color {
LayerColor::Preset(i) => match LAYER_COLORS.get(usize::from(i)) {
Some((_, rgb)) => (i64::from(ai_layer_index(i)), *rgb),
None => (-1, [79, 128, 255]),
},
LayerColor::Custom(rgb) => (-1, rgb),
}
}
pub(crate) fn unit(v: f32) -> f32 {
if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }
}
+2 -1
View File
@@ -471,8 +471,9 @@ impl<'a> Reader<'a> {
a.dim = flag(4, false).then_some(dim_pct.round() as u8);
let index = n.get(at).copied().unwrap_or(0.0);
let rgb = |i: usize| n.get(at + i).map_or(0, |v| v.clamp(0.0, 255.0) as u8);
// The file numbers some presets differently from `LAYER_COLORS` (#6502).
a.color = Some(if (0.0..vectorcraft_doc::LAYER_COLORS.len() as f64).contains(&index) && index.fract() == 0.0 {
LayerColor::Preset(index as u8)
LayerColor::Preset(crate::ai_ops::preset_of_ai_layer_index(index as u8))
} else {
LayerColor::Custom([rgb(1), rgb(2), rgb(3)])
});
+26 -2
View File
@@ -8522,20 +8522,44 @@ fn layer_with(ops: &str) -> Node {
/// the colour index and the last one change nothing.
#[test]
fn the_template_flag_of_a_layer() {
// Index 4 is the file's Magenta (#6502).
let l = layer_with("1 1 0 0 1 0 0 4 255 79 255 1 50 0");
let NodeKind::Layer { template, printable, dim_images, color, .. } = l.kind else { panic!() };
assert!(template && !printable && l.locked);
assert_eq!((dim_images, color), (Some(50), LayerColor::Preset(4)));
assert_eq!((dim_images, color), (Some(50), LayerColor::Preset(5)));
// Index 5 is the file's Cyan (#6502).
for ops in ["1 1 1 1 1 0 1 5 1 2 3 1 30 0", "1 1 1 1 1 0 0 5 1 2 3 1 30 1", "1 1 1 1 1 0 1 5 1 2 3 1 30"] {
let l = layer_with(ops);
let NodeKind::Layer { template, printable, dim_images, color, .. } = l.kind else { panic!() };
assert!(template && printable && !l.locked, "{ops}");
assert_eq!((dim_images, color), (Some(30), LayerColor::Preset(5)), "{ops}");
assert_eq!((dim_images, color), (Some(30), LayerColor::Preset(6)), "{ops}");
}
let l = layer_with("1 1 1 1 0 0 1 0 79 128 255 0 50 0");
assert!(matches!(l.kind, NodeKind::Layer { template: false, .. }));
}
/// #6502: an `.ai` file numbers Magenta, Cyan and Yellow 4, 5 and 6 (the index and RGB pairs of
/// the `Lb` records in files users sent), so they open as those presets; the index decides, not
/// the RGB beside it, and the other presets keep their position.
#[test]
fn layer_colour_indexes_follow_the_file_format() {
let color = |ops: &str| match layer_with(ops).kind {
NodeKind::Layer { color, .. } => color,
_ => panic!(),
};
let preset = |name: &str| {
let i = vectorcraft_doc::LAYER_COLORS.iter().position(|(n, _)| *n == name).unwrap();
LayerColor::Preset(u8::try_from(i).unwrap())
};
for (index, rgb, name) in
[(3, "79 79 255", "Blue"), (4, "255 79 255", "Magenta"), (5, "79 255 255", "Cyan"), (6, "255 255 79", "Yellow"), (7, "128 128 128", "Gray")]
{
assert_eq!(color(&format!("1 1 1 1 0 0 0 {index} {rgb} 0 50 0")), preset(name), "index {index}");
}
// The RGB beside an index doesn't change the preset.
assert_eq!(color("1 1 1 1 0 0 0 4 255 255 79 0 50 0"), preset("Magenta"));
}
/// The colour index is one place earlier in eleven and twelve operands, as in ten, and the twelfth
/// is the dimming percentage; with nine it is one place earlier still.
#[test]
+2 -2
View File
@@ -65,8 +65,8 @@ use std::sync::Arc;
use vectorcraft_color::cms::Cms;
use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{
AppearanceItem, AreaOptions, CharPosition, CharStyle, CompositeFont, Document, FillLayer, ImageBlob, Justify, LAYER_COLORS, LayerColor, Node,
NodeKind, ParaDirection, ParaStyle, PathAlign, PathEffect, StrokeLayer, TextKind, TextObject,
AppearanceItem, AreaOptions, CharPosition, CharStyle, CompositeFont, Document, FillLayer, ImageBlob, Justify, Node, NodeKind, ParaDirection,
ParaStyle, PathAlign, PathEffect, StrokeLayer, TextKind, TextObject,
};
use vectorcraft_effects::stroke;
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, SubPath, Vec2};
+29
View File
@@ -940,6 +940,35 @@ fn names_outside_windows_1252_are_replaced_and_kept_apart() {
assert_eq!(inks(&read), want);
}
/// #6502: the legacy writer gives Yellow, Magenta and Cyan (presets 4, 5 and 6) the file's
/// indexes 6, 4 and 5, and they open as the same presets.
#[test]
fn layer_presets_are_written_with_the_files_colour_index() {
let mut doc = Document::new(100.0, 100.0);
doc.layers.clear();
for i in 3..8u8 {
let id = doc.alloc_id();
doc.layers.push(Arc::new(Node::layer(id, &format!("P{i}"), vectorcraft_doc::LayerColor::Preset(i))));
}
let out = write(&doc);
let text = String::from_utf8_lossy(&out.bytes).replace("\r\n", "\n");
for (i, index) in [(3, 3), (4, 6), (5, 4), (6, 5), (7, 7)] {
let [r, g, b] = vectorcraft_doc::LAYER_COLORS[i].1;
let want = format!(" 0 {index} {r} {g} {b} Lb\n(P{i}) Ln\n");
assert!(text.contains(&want), "missing {want:?} in\n{text}");
}
let read = crate::import(&out.bytes).unwrap().document;
let colors: Vec<_> = read
.layers
.iter()
.filter_map(|l| match l.kind {
NodeKind::Layer { color, .. } => Some(color),
_ => None,
})
.collect();
assert_eq!(colors, (3..8u8).map(vectorcraft_doc::LayerColor::Preset).collect::<Vec<_>>());
}
#[test]
fn losses_are_warned() {
let mut doc = sample();
+5 -19
View File
@@ -5,9 +5,9 @@ use super::encoding::{
use super::{
Affine, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule, Gradient,
GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE,
LAYER_COLORS, LAYER_NAME, LIVE, LayerColor, LegacyAiOptions, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS, Paint, ParaDirection,
ParaStyle, PathData, ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE,
TYPE_ATTRIBUTES, TextKind, TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke,
LAYER_NAME, LIVE, LegacyAiOptions, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS, Paint, ParaDirection, ParaStyle, PathData,
ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind,
TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke,
};
pub(super) struct Writer<'a> {
@@ -135,14 +135,7 @@ impl<'a> Writer<'a> {
/// A top-level layer. False when it isn't a layer.
pub(super) fn layer(&mut self, n: &Node) -> bool {
let NodeKind::Layer { color, template, printable, children, clip, preview, dim_images } = &n.kind else { return false };
let [r, g, b] = match color {
LayerColor::Preset(i) => LAYER_COLORS.get(usize::from(*i)).map_or([79, 128, 255], |c| c.1),
LayerColor::Custom(rgb) => *rgb,
};
let index = match color {
LayerColor::Preset(i) if usize::from(*i) < LAYER_COLORS.len() => i64::from(*i),
_ => -1,
};
let (index, [r, g, b]) = ai_ops::layer_color(*color);
let flag = |b: bool| u8::from(b);
let name = self.layer_name(n.name.as_deref().unwrap_or("Layer"));
// A template layer doesn't print and shows dimmed.
@@ -173,14 +166,7 @@ impl<'a> Writer<'a> {
}
let art = std::mem::take(&mut self.hidden_art);
let NodeKind::Layer { color, template, printable, .. } = &n.kind else { return false };
let [r, g, b] = match color {
LayerColor::Preset(i) => LAYER_COLORS.get(usize::from(*i)).map_or([79, 128, 255], |c| c.1),
LayerColor::Custom(rgb) => *rgb,
};
let index = match color {
LayerColor::Preset(i) if usize::from(*i) < LAYER_COLORS.len() => i64::from(*i),
_ => -1,
};
let (index, [r, g, b]) = ai_ops::layer_color(*color);
let name = format!("{} (hidden)", n.name.as_deref().unwrap_or("Layer"));
let name = self.layer_name(&name);
let _ = writeln!(
+9 -1
View File
@@ -10,6 +10,7 @@
//! run past [`crate::Limits`].
mod array;
pub(crate) mod extensions;
mod global;
mod json;
mod math;
@@ -358,6 +359,13 @@ fn object_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResu
/// `Object.prototype.toString` (section 15.2.4.2).
fn object_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
// With `$.localize` on, a localization object gives its text for the current locale.
if interp.localization.enabled()
&& let Value::Object(id) = this
&& let Some(text) = extensions::localized_text(interp, id)?
{
return Ok(text);
}
let class = match &this {
Value::Undefined => "Undefined",
Value::Null => "Null",
@@ -472,7 +480,7 @@ fn function_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsRe
Ok(p) => p,
Err(e) => return Err(interp.throw(ErrorType::Syntax, &e.message)),
};
let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: interp.source_tag });
let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: interp.source_tag, strict_writes: interp.writes_mode() });
let code = match compile_constructed_function(&program, &source) {
Ok(Some(code)) => code,
Ok(None) => return Err(interp.throw(ErrorType::Syntax, "the parameters or body do not form one function")),
+400
View File
@@ -0,0 +1,400 @@
//! The script extensions a host adds with [`crate::Realm::add_script_extensions`]: the global
//! `localize`, the global `toSource` and `Object.prototype.toSource`, and the getters behind
//! [`crate::ScriptItem`]. `localize` and `Object.prototype.toString` read the locale of
//! [`Localization`].
//!
//! `toSource` writes primitives, arrays and plain objects as the text of a JavaScript expression
//! that makes the same value: strings in double quotes, property names quoted, properties in the
//! order the object lists them, a hole in an array as an empty element. It never runs a getter or
//! a script's own method: a getter, a function, a date, a regular expression, an error, a boxed
//! primitive, an array with properties besides its elements, an object whose prototype is not
//! `Object.prototype`, a property that is not enumerable or any other object that is not plain
//! data throws a `TypeError`, and so does an object that contains itself. A value nested deeper, with more entries or longer text than the
//! realm's [`crate::DataLimits`] allow throws a `RangeError`. Every value written takes fuel.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use super::{Builder, arg, method};
use crate::heap::{Class, NativeFn, ObjId, Prop, key};
use crate::interp::{ErrorType, Interp, JsResult};
use crate::value::{Value, array_index, index_key, number_to_str};
use crate::{ScriptItem, bytecode::Code};
/// The locale `localize` looks a localization object up in, and whether `toString` gives an
/// object's localized text (`$.localize`). A host changes it through [`crate::Realm::localization`].
#[derive(Debug)]
pub struct Localization {
locale: RefCell<String>,
platform: Cell<Option<&'static str>>,
enabled: Cell<bool>,
}
impl Default for Localization {
fn default() -> Self {
Self { locale: RefCell::new("en".into()), platform: Cell::new(None), enabled: Cell::new(false) }
}
}
impl Localization {
/// The locale, a language and an optional region joined by `_`, such as `de_DE`.
pub fn locale(&self) -> String {
self.locale.borrow().clone()
}
pub fn set_locale(&self, locale: &str) {
*self.locale.borrow_mut() = locale.to_string();
}
/// The platform suffix the lookup tries first (`Win`, `Mac` or `Unix`), or none.
pub fn platform(&self) -> Option<&'static str> {
self.platform.get()
}
pub fn set_platform(&self, platform: Option<&'static str>) {
self.platform.set(platform);
}
/// Does `Object.prototype.toString` give an object's localized text?
pub fn enabled(&self) -> bool {
self.enabled.get()
}
pub fn set_enabled(&self, on: bool) {
self.enabled.set(on);
}
/// The property names a localization object is looked up by, in order: the locale with the
/// platform suffix, the locale, its language with the suffix, its language, and `en`.
pub fn keys(&self) -> Vec<String> {
let locale = self.locale();
let language = locale.split('_').next().unwrap_or_default().to_string();
let mut keys = Vec::with_capacity(5);
for name in [&locale, &language] {
if let Some(p) = self.platform() {
keys.push(format!("{name}_{p}"));
}
keys.push(name.clone());
}
keys.push("en".into());
let mut unique = Vec::with_capacity(keys.len());
for k in keys {
if !k.is_empty() && !unique.contains(&k) {
unique.push(k);
}
}
unique
}
}
pub(crate) fn install(interp: &mut Interp) -> JsResult<()> {
let global = interp.global;
method(interp, global, "localize", localize, 1)?;
method(interp, global, "toSource", to_source, 1)?;
let object_proto = interp.intrinsics.object_proto;
method(interp, object_proto, "toSource", to_source_method, 0)
}
/// The text of the localization object `id` for the current locale: the string value of the first
/// of [`Localization::keys`] that the object has as its own data property. No getter runs.
pub(crate) fn localized_text(interp: &mut Interp, id: ObjId) -> JsResult<Option<Value>> {
if !matches!(interp.object(id)?.class, Class::Ordinary) {
return Ok(None);
}
for k in interp.localization.keys() {
interp.use_fuel()?;
if let Some(Prop::Data { value: v @ Value::String(_), .. }) = interp.get_own_property(id, &key(&k))? {
return Ok(Some(v));
}
}
Ok(None)
}
/// `localize(object, ...values)`: the text of a localization object for the current locale, or of
/// a string, with `%1` replaced by the first value, `%2` by the second and so on. A number with no
/// value stays as it is. An object with none of the locale's names is returned as it is.
fn localize(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
let mark = interp.temp_roots.len();
interp.temp_roots.extend(args.iter().cloned());
let r = localize_rooted(interp, args);
interp.temp_roots.truncate(mark);
r
}
fn localize_rooted(interp: &mut Interp, args: &[Value]) -> JsResult<Value> {
let text = match args.first() {
Some(Value::String(s)) => s.flat(),
Some(first @ Value::Object(id)) => match localized_text(interp, *id)? {
Some(Value::String(s)) => s.flat(),
_ => return Ok(first.clone()),
},
_ => return Err(interp.type_error("localize: the first argument must be a localization object or a string")),
};
let values = args.get(1..).unwrap_or_default();
let mut converted: Vec<Option<Rc<[u16]>>> = vec![None; values.len()];
let mut out = Builder::new();
let (mut start, mut i) = (0, 0);
while let Some(&u) = text.get(i) {
if u != u16::from(b'%') {
i += 1;
continue;
}
let digits = text.get(i + 1..).unwrap_or_default().iter().take(4).take_while(|d| (0x30..=0x39).contains(*d)).count();
let n = text.get(i + 1..i + 1 + digits).unwrap_or_default().iter().fold(0usize, |n, d| n * 10 + usize::from(*d - 0x30));
let Some(slot) = n.checked_sub(1).filter(|&k| digits > 0 && k < values.len()) else {
i += 1 + digits;
continue;
};
out.push_units(interp, text.get(start..i).unwrap_or_default())?;
let units = match converted.get(slot).cloned().flatten() {
Some(u) => u,
None => {
let s = interp.to_str(&arg(values, slot))?.flat();
if let Some(c) = converted.get_mut(slot) {
*c = Some(s.clone());
}
s
}
};
out.push_units(interp, &units)?;
i += 1 + digits;
start = i;
}
out.push_units(interp, text.get(start..).unwrap_or_default())?;
Ok(out.finish(interp))
}
/// The global `toSource(value)`.
fn to_source(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
write_source(interp, arg(args, 0))
}
/// `Object.prototype.toSource()`: the source of the value it is called on.
fn to_source_method(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
write_source(interp, this)
}
fn write_source(interp: &mut Interp, v: Value) -> JsResult<Value> {
let mark = interp.temp_roots.len();
interp.temp_roots.push(v.clone());
let mut w = Source { out: Builder::new(), ancestors: Vec::new(), entries: 0 };
let r = w.value(interp, &v);
interp.temp_roots.truncate(mark);
r?;
Ok(w.out.finish(interp))
}
/// A value being written as source: the text so far, the objects it is inside and the entries it
/// has written.
struct Source {
out: Builder,
ancestors: Vec<ObjId>,
entries: usize,
}
impl Source {
fn push(&mut self, interp: &mut Interp, s: &str) -> JsResult<()> {
self.out.push_str(interp, s)?;
self.check_length(interp)
}
fn check_length(&mut self, interp: &mut Interp) -> JsResult<()> {
self.fit(interp, 0)
}
/// Checks that `more` code units can be added without passing the length limit.
fn fit(&mut self, interp: &mut Interp, more: usize) -> JsResult<()> {
if self.out.units.len().saturating_add(more) > interp.data_limits.bytes {
return Err(interp.throw(ErrorType::Range, "toSource: the source is too long"));
}
Ok(())
}
fn count(&mut self, interp: &mut Interp, n: usize) -> JsResult<()> {
self.entries = self.entries.saturating_add(n);
if self.entries > interp.data_limits.entries {
return Err(interp.throw(ErrorType::Range, "toSource: the value has too many entries"));
}
Ok(())
}
fn value(&mut self, interp: &mut Interp, v: &Value) -> JsResult<()> {
interp.use_fuel()?;
match v {
Value::Undefined => self.push(interp, "(void 0)"),
Value::Null => self.push(interp, "null"),
Value::Bool(b) => self.push(interp, if *b { "true" } else { "false" }),
Value::Number(n) if *n == 0.0 && n.is_sign_negative() => self.push(interp, "-0"),
Value::Number(n) => {
self.out.push_units(interp, &number_to_str(*n).flat())?;
self.check_length(interp)
}
Value::String(s) => self.quoted(interp, &s.flat()),
Value::Object(id) => self.object(interp, *id),
}
}
/// `s` in double quotes, with the characters a string literal cannot hold escaped. The length
/// limit is checked before each addition, so a string that grows as it is escaped throws a
/// `RangeError` before the text takes more heap than the limit allows.
fn quoted(&mut self, interp: &mut Interp, s: &[u16]) -> JsResult<()> {
self.fit(interp, 1)?;
self.out.push_str(interp, "\"")?;
let mut start = 0;
for (i, &c) in s.iter().enumerate() {
let escape = match c {
0x22 => "\\\"".to_string(),
0x5C => "\\\\".to_string(),
0x0A => "\\n".to_string(),
0x0D => "\\r".to_string(),
0x09 => "\\t".to_string(),
0x08 => "\\b".to_string(),
0x0C => "\\f".to_string(),
0..=0x1F | 0x7F | 0x2028 | 0x2029 | 0xD800..=0xDFFF => format!("\\u{c:04X}"),
_ => continue,
};
self.fit(interp, (i - start).saturating_add(escape.len()))?;
self.out.push_units(interp, s.get(start..i).unwrap_or_default())?;
self.out.push_str(interp, &escape)?;
start = i + 1;
}
self.fit(interp, s.len().saturating_sub(start).saturating_add(1))?;
self.out.push_units(interp, s.get(start..).unwrap_or_default())?;
self.push(interp, "\"")
}
fn object(&mut self, interp: &mut Interp, id: ObjId) -> JsResult<()> {
if self.ancestors.contains(&id) {
return Err(interp.type_error("toSource: the value contains itself"));
}
if self.ancestors.len() >= interp.data_limits.depth {
return Err(interp.throw(ErrorType::Range, "toSource: the value is nested too deep"));
}
let (object_proto, array_proto) = (interp.intrinsics.object_proto, interp.intrinsics.array_proto);
let o = interp.object(id)?;
// The text makes an array or an object from `Object.prototype` with enumerable properties
// only, so an array with other properties, an object with another prototype and a property
// that is not enumerable are refused.
let enumerable = o.props.iter().all(|(_, p)| p.enumerable());
let shape = match &o.class {
Class::Array { length, .. }
if o.proto == Some(array_proto) && enumerable && o.props.iter().all(|(k, _)| array_index(k).is_some_and(|i| i < *length)) =>
{
Some(Err(*length))
}
Class::Array { .. } => return Err(interp.type_error(NOT_PLAIN_ARRAY)),
Class::Ordinary if o.proto == Some(object_proto) && enumerable => {
Some(Ok(o.props.iter().map(|(k, p)| (k.clone(), p.clone())).collect::<Vec<_>>()))
}
Class::Ordinary => return Err(interp.type_error(NOT_PLAIN_OBJECT)),
_ => None,
};
let Some(shape) = shape else {
let what = format!("toSource: a {} cannot be written as source", interp.object(id)?.class.name());
return Err(interp.type_error(&what));
};
self.ancestors.push(id);
let r = match shape {
Err(length) => self.array(interp, id, length),
Ok(props) => self.plain(interp, props),
};
self.ancestors.pop();
r
}
fn array(&mut self, interp: &mut Interp, id: ObjId, length: u32) -> JsResult<()> {
self.count(interp, length as usize)?;
self.push(interp, "[")?;
let mut hole = false;
for i in 0..length {
interp.use_fuel()?;
if i > 0 {
self.push(interp, ", ")?;
}
let prop = interp.object(id)?.props.get(&index_key(i)).cloned();
hole = prop.is_none();
match prop {
None => {}
Some(Prop::Data { value, .. }) => self.value(interp, &value)?,
Some(Prop::Accessor { .. }) => return Err(interp.type_error("toSource: a getter cannot be written as source")),
}
}
// A hole at the end needs a comma of its own, or the array would be one shorter.
if hole {
self.push(interp, ",")?;
}
self.push(interp, "]")
}
fn plain(&mut self, interp: &mut Interp, props: Vec<(crate::heap::Key, Prop)>) -> JsResult<()> {
self.count(interp, props.len())?;
self.push(interp, "({")?;
for (n, (k, prop)) in props.into_iter().enumerate() {
interp.use_fuel()?;
if n > 0 {
self.push(interp, ", ")?;
}
let Prop::Data { value, .. } = prop else { return Err(interp.type_error("toSource: a getter cannot be written as source")) };
self.quoted(interp, &k)?;
self.push(interp, ":")?;
self.value(interp, &value)?;
}
self.push(interp, "})")
}
}
const NOT_PLAIN_ARRAY: &str =
"toSource: an array with properties besides its elements, a property that is not enumerable or another prototype cannot be written as source";
const NOT_PLAIN_OBJECT: &str =
"toSource: an object with a property that is not enumerable, or whose prototype is not Object.prototype, cannot be written as source";
/// The getter behind `item`; [`ScriptItem::Global`] is a value and has none.
pub(crate) fn getter(item: ScriptItem) -> Option<(&'static str, NativeFn)> {
match item {
ScriptItem::Global => None,
ScriptItem::LastError => Some(("error", last_error)),
ScriptItem::Line => Some(("line", line)),
ScriptItem::Stack => Some(("stack", stack)),
}
}
fn last_error(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult<Value> {
Ok(interp.last_error.clone())
}
fn line(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult<Value> {
let line = interp.script_frames().last().map_or(0, |(code, pc, _)| code.line_column(pc.saturating_sub(1)).0);
Ok(Value::Number(f64::from(line)))
}
/// The most frames `$.stack` lists, and the most characters of a function's name it shows.
const STACK_FRAMES: usize = 64;
const STACK_NAME_UNITS: usize = 64;
/// The script's frames, outermost first, one a line: the function's name and the line it is at.
fn stack(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult<Value> {
let frames: Vec<(Rc<Code>, usize, bool)> = interp.script_frames().map(|(c, pc, f)| (c.clone(), pc, f)).collect();
let skip = frames.len().saturating_sub(STACK_FRAMES);
let mut text = String::new();
if skip > 0 {
text.push_str("…\n");
}
for (code, pc, function) in frames.iter().skip(skip) {
interp.use_fuel()?;
let name = if *function { function_name(code) } else { "[global]".to_string() };
let line = code.line_column(pc.saturating_sub(1)).0;
text.push_str(&format!("{name}:{line}\n"));
}
let mut out = Builder::new();
out.push_str(interp, &text)?;
Ok(out.finish(interp))
}
/// The name `code` has: its own as a function expression's, or its declaration's, cut to
/// [`STACK_NAME_UNITS`]; `[anonymous]` without one.
fn function_name(code: &Code) -> String {
match code.self_name.as_ref().or(code.decl_name.as_ref()) {
Some(name) => String::from_utf16_lossy(name.get(..STACK_NAME_UNITS).unwrap_or(&**name)),
None => "[anonymous]".to_string(),
}
}
+5
View File
@@ -173,6 +173,9 @@ pub(crate) struct Source {
pub(crate) text: Rc<[u16]>,
/// Which source this is, as [`crate::Realm::error_source`] gives it.
pub(crate) tag: u32,
/// Writes to read-only and getter-only properties, and new properties on objects that are
/// not extensible, throw in this source's code even where it is not strict mode code.
pub(crate) strict_writes: bool,
}
#[derive(Debug)]
@@ -192,6 +195,8 @@ pub(crate) struct Code {
pub(crate) strict: bool,
/// A function expression's own name, bound inside it.
pub(crate) self_name: Option<Key>,
/// A function declaration's name, which `$.stack` shows.
pub(crate) decl_name: Option<Key>,
pub(crate) params: Vec<Key>,
pub(crate) vars: Vec<Key>,
/// Function declarations to instantiate on entry: their names and indices in `functions`.
+3
View File
@@ -1193,6 +1193,7 @@ impl<'a> FnCompiler<'a> {
max_stack: 0,
strict: self.strict,
self_name: meta.self_name,
decl_name: meta.decl_name,
params: meta.params,
vars: hoisted.vars,
fun_decls: hoisted.fun_decls,
@@ -1209,6 +1210,7 @@ impl<'a> FnCompiler<'a> {
#[derive(Default)]
struct Meta {
self_name: Option<Key>,
decl_name: Option<Key>,
params: Vec<Key>,
span: (u32, u32),
}
@@ -1231,6 +1233,7 @@ pub(crate) fn compile_function(ast: &Ast, fid: FunctionId, source: &Rc<Source>,
c.emit(Op::Return);
let name = f.name.as_ref().map(|n| key_of(&n.name));
let meta = Meta {
decl_name: if f.kind == FunctionKind::Declaration { name.clone() } else { None },
self_name: if bind_name && f.kind == FunctionKind::Expression { name } else { None },
params: f.params.iter().map(|p| key_of(&p.name)).collect(),
span: (f.span.start, f.span.end),
+202
View File
@@ -0,0 +1,202 @@
//! The script extensions: `localize`, `toSource`, host properties with setters and the items that
//! read the interpreter's own state.
use std::cell::RefCell;
use std::rc::Rc;
use super::*;
fn realm() -> Realm {
let mut r = Realm::new();
r.add_script_extensions().unwrap();
r
}
fn text(r: &mut Realm, source: &str) -> String {
match r.run(source) {
Ok(JsValue::String(s)) => s,
other => panic!("{source}: {other:?}"),
}
}
fn thrown(r: &mut Realm, source: &str) -> String {
match r.run(source) {
Err(e) if e.kind == RunErrorKind::Thrown => e.message,
other => panic!("{source}: {other:?}"),
}
}
#[test]
fn localize_looks_up_the_locale_with_its_platform_then_its_language_then_english() {
let mut r = realm();
let l = r.localization();
l.set_locale("de_DE");
l.set_platform(Some("Win"));
assert_eq!(l.keys(), ["de_DE_Win", "de_DE", "de_Win", "de", "en"]);
let all = "({en: 'e', de: 'd', de_Win: 'dw', de_DE: 'dd', de_DE_Win: 'ddw'})";
assert_eq!(text(&mut r, &format!("localize({all})")), "ddw");
assert_eq!(text(&mut r, "localize({en: 'e', de: 'd', de_Win: 'dw', de_DE: 'dd'})"), "dd");
assert_eq!(text(&mut r, "localize({en: 'e', de: 'd', de_Win: 'dw'})"), "dw");
assert_eq!(text(&mut r, "localize({en: 'e', de: 'd', DE: 'x'})"), "d", "names are case sensitive");
assert_eq!(text(&mut r, "localize({en: 'e', fr: 'f'})"), "e");
l.set_platform(None);
assert_eq!(text(&mut r, &format!("localize({all})")), "dd");
l.set_locale("en");
assert_eq!(l.keys(), ["en"]);
}
#[test]
fn localize_replaces_numbered_values_once_and_leaves_the_rest() {
let mut r = realm();
assert_eq!(text(&mut r, "localize({en: '%2 before %1, %1 again'}, 'a', 'b')"), "b before a, a again");
assert_eq!(text(&mut r, "localize('%1%% %3 %0 %', 'x', '%1')"), "x%% %3 %0 %");
assert_eq!(text(&mut r, "localize('%2', 'x', '%1')"), "%1", "a value is not replaced again");
assert_eq!(text(&mut r, "localize('%1', {toString: function () { return 'o'; }})"), "o");
assert_eq!(text(&mut r, "var n = 0; var v = {toString: function () { n++; return 'v'; }}; localize('%1%1', v) + n"), "vv1");
}
#[test]
fn localize_gives_back_an_object_without_the_locale_and_runs_no_getter() {
let mut r = realm();
r.localization().set_locale("fr_FR");
assert_eq!(r.run("var o = {de: 'd'}; localize(o) === o").unwrap(), JsValue::Bool(true));
let source = "var hit = false; var o = {get fr() { hit = true; return 'g'; }, en: 'e'}; localize(o) + ',' + hit";
assert_eq!(text(&mut r, source), "e,false");
assert_eq!(text(&mut r, "localize({fr: 1, en: 'e'})"), "e", "a value that is not a string is passed over");
for bad in ["localize()", "localize(1)", "localize(null)"] {
assert!(thrown(&mut r, bad).starts_with("TypeError"), "{bad}");
}
}
#[test]
fn to_string_localizes_an_object_only_while_localization_is_on() {
let mut r = realm();
r.localization().set_locale("ja");
let source = "String({en: 'Hello', ja: 'こんにちは'}) + '|' + String({x: 1})";
assert_eq!(text(&mut r, source), "[object Object]|[object Object]");
r.localization().set_enabled(true);
assert_eq!(text(&mut r, source), "こんにちは|[object Object]");
}
#[test]
fn to_source_writes_plain_data_as_an_expression_that_makes_it_again() {
let mut r = realm();
let source = r#"toSource({a: [1, "x\n\"", null, true, -0, 1.5e300], "b c": {d: undefined}, e: {}, f: []})"#;
let want = r#"({"a":[1, "x\n\"", null, true, -0, 1.5e+300], "b c":({"d":(void 0)}), "e":({}), "f":[]})"#;
assert_eq!(text(&mut r, source), want);
assert_eq!(text(&mut r, "[1, , 3].toSource() + '|' + [1, ,].toSource() + '|' + [,].toSource()"), "[1, , 3]|[1, ,]|[,]");
assert_eq!(
text(&mut r, "toSource(NaN) + toSource(-Infinity) + toSource() + toSource('\\u2028\\ud800\\x7f')"),
"NaN-Infinity(void 0)\"\\u2028\\uD800\\u007F\""
);
assert_eq!(text(&mut r, "(5).toSource() + 'a'.toSource() + true.toSource()"), "5\"a\"true");
// Parsed back, the text makes the same value: holes, -0 and undefined included.
let round = r#"var v = {a: [1, , "q\u0001'\\"], b: {"0": -0, "": undefined, "x y": [[]]}};
var w = eval(toSource(v));
[JSON.stringify(w) === JSON.stringify(v), w.a.length, 1 in w.a, 1 / w.b[0], "" in w.b].join()"#;
assert_eq!(text(&mut r, round), "true,3,false,-Infinity,true");
let shared = "var s = {k: 1}; toSource([s, s])";
assert_eq!(text(&mut r, shared), r#"[({"k":1}), ({"k":1})]"#, "a value seen twice that is not its own ancestor is written twice");
}
#[test]
fn to_source_refuses_what_is_not_plain_data() {
let mut r = realm();
for (source, kind) in [
("var o = {}; o.o = o; toSource(o)", "TypeError"),
("var a = [1]; a.push([a]); toSource(a)", "TypeError"),
("toSource({f: function () {}})", "TypeError"),
("toSource({get g() { return 1; }})", "TypeError"),
("toSource(new Date(0))", "TypeError"),
("toSource([/x/])", "TypeError"),
("toSource(new Error('e'))", "TypeError"),
("toSource(new String('s'))", "TypeError"),
("var a = [1]; a.extra = 2; toSource(a)", "TypeError"),
("toSource(Object.create(Array.prototype))", "TypeError"),
("var o = {}; Object.defineProperty(o, 'hidden', {value: 1}); toSource(o)", "TypeError"),
("var a = [0]; Object.defineProperty(a, '0', {value: 1, enumerable: false}); toSource(a)", "TypeError"),
("toSource(Object.create({a: 1}))", "TypeError"),
("toSource(Object.create(null))", "TypeError"),
("function F() { this.a = 1; } toSource(new F())", "TypeError"),
("var d = []; for (var i = 0; i < 70; i++) d = [d]; toSource(d)", "RangeError"),
("var a = []; a.length = 2000000; toSource(a)", "RangeError"),
] {
assert!(thrown(&mut r, source).starts_with(kind), "{source}");
}
assert!(thrown(&mut r, "toSource({get g() { throw 'ran'; }})").starts_with("TypeError"), "the getter does not run");
}
#[test]
fn to_source_of_a_string_with_many_escapes_throws_a_range_error_before_the_heap_limit() {
let mut r = realm();
// A run's heap in the web build. 2^23 control characters, each written as six, pass a quarter
// of it, which is all one string may take.
r.set_limits(Limits { heap_bytes: 128 << 20, ..Limits::default() });
let source = "var s = '\\x01'; for (var i = 0; i < 23; i++) s = s + s;
try { toSource(s); 'no error'; } catch (e) { e.name + ': ' + e.message; }";
assert_eq!(text(&mut r, source), "RangeError: toSource: the source is too long");
assert_eq!(text(&mut r, "toSource('a\\x01a\\x01a\\x01a\\x01').length + ''"), "30", "short strings are written whole");
}
#[test]
fn to_source_stops_at_the_fuel_limit() {
let mut r = realm();
r.set_limits(Limits { fuel: 200_000, ..Limits::default() });
let e = r.run("var a = []; for (var i = 0; i < 1000; i++) a.push(i); while (true) toSource(a);").unwrap_err();
assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel));
}
#[test]
fn script_items_read_the_global_object_the_last_error_the_line_and_the_stack() {
let mut r = realm();
let items = ["global", "error", "line", "stack"]
.into_iter()
.zip([ScriptItem::Global, ScriptItem::LastError, ScriptItem::Line, ScriptItem::Stack])
.map(|(n, i)| (n.to_string(), HostItem::Script(i)))
.collect();
r.define_host_object("h", items).unwrap();
assert_eq!(r.run("var later = 2; h.global === this && h.global.later === 2").unwrap(), JsValue::Bool(true));
let source = "var before = h.error; try { null.x; } catch (e) {} var caught = h.error; try { throw 'text'; } catch (e) {}
[before === null, caught instanceof TypeError, h.error === caught].join()";
assert_eq!(text(&mut r, source), "true,true,true");
let source = "try { try { throw new RangeError('first'); } finally { try { throw new TypeError('second'); } catch (e) {} } } catch (e) {}
h.error.message";
assert_eq!(text(&mut r, source), "second", "an exception a finally block passes on is not thrown again");
let source = "try { [1].map(function () { try { throw new RangeError('first'); } finally { try { throw new TypeError('second'); } catch (e) {} } }); } catch (e) {}
h.error.message";
assert_eq!(text(&mut r, source), "second", "nor is one that leaves a built-in's callback");
let source = "var kept; try { throw new RangeError('first'); } catch (e) { kept = e; } try { throw new TypeError('second'); } catch (e) {}
try { throw kept; } catch (e) {} h.error.message";
assert_eq!(text(&mut r, source), "first", "throwing a caught error again makes it the last one");
assert_eq!(r.run("h.error").unwrap(), JsValue::Null, "each run starts without one");
assert_eq!(r.run("\n\n h.line").unwrap(), JsValue::Number(3.0));
let source = "function outer() {\n return inner();\n}\nfunction inner() {\n return h.stack;\n}\nvar f = function () { return outer(); };\nf()";
assert_eq!(text(&mut r, source), "[global]:8\n[anonymous]:7\nouter:2\ninner:5\n");
let source = "function maker() {\n function named() {\n return h.stack;\n }\n return named;\n}\nvar later = maker();\nlater()";
assert_eq!(text(&mut r, source), "[global]:8\nnamed:3\n", "a declared function keeps its name after its declaring function returns");
let deep = "function d(n) { return n ? d(n - 1) : h.stack; } var s = d(100).split('\\n'); s.length + ',' + s[0]";
let lines = text(&mut r, deep);
assert_eq!(lines, "66,…", "64 frames, the line that some are left out, and the last line's end");
assert!(thrown(&mut r, "'use strict'; h.line = 1").starts_with("TypeError"), "the items cannot be assigned");
}
#[test]
fn a_host_property_reads_its_getter_and_gives_assignments_to_its_setter() {
let mut r = Realm::new();
let seen: Rc<RefCell<Vec<Data>>> = Rc::default();
let log = seen.clone();
let get: HostFunction = Rc::new(move |_| Ok(Data::String("got".into())));
let set: HostFunction = Rc::new(move |args| match args.first() {
Some(Data::Number(n)) if *n < 0.0 => Err(HostError::Range("no negatives".into())),
Some(d) => {
log.borrow_mut().push(d.clone());
Ok(Data::Undefined)
}
None => Ok(Data::Undefined),
});
r.define_host_object("h", vec![("p".into(), HostItem::Property { get, set })]).unwrap();
assert_eq!(r.run("h.p = 5; h.p = {a: [1]}; h.p").unwrap(), JsValue::String("got".into()));
assert_eq!(*seen.borrow(), [Data::Number(5.0), Data::Object(vec![("a".into(), Data::Array(vec![Data::Number(1.0)]))])]);
let e = r.run("try { h.p = -1; } catch (e) { e instanceof RangeError && e.message }").unwrap();
assert_eq!(e, JsValue::String("no negatives".into()));
}
+2 -2
View File
@@ -184,7 +184,7 @@ pub(crate) enum Func {
Native { f: NativeFn, kind: NativeKind, constructor: bool, name: &'static str },
Host { f: Rc<HostFn>, name: Rc<str> },
Data { f: DataFn, name: Rc<str>, read: crate::host::ArgRead },
Eval { f: DataFn, end: Rc<dyn Fn()>, name: Rc<str> },
Eval { f: DataFn, end: Rc<dyn Fn()>, strict_writes: Rc<dyn Fn() -> Option<bool>>, name: Rc<str> },
Bound { target: ObjId, this: Value, args: Rc<[Value]> },
}
@@ -529,7 +529,7 @@ fn mark_code(code: &Code, strings: &mut StringSizes) {
strings.add(flags);
}
let keys = code.names.iter().chain(&code.params).chain(&code.vars).chain(code.fun_decls.iter().map(|(k, _)| k));
for k in keys.chain(&code.self_name) {
for k in keys.chain(&code.self_name).chain(&code.decl_name) {
strings.add_key(k);
}
stack.extend(code.functions.iter().map(|f| &**f));
+63 -6
View File
@@ -64,6 +64,11 @@ pub enum HostError {
/// it only while [`crate::Realm::wait_flag`] is set; otherwise the run ends with an internal
/// error.
Wait,
/// `error`, with a mark the host chose. When the run ends with the exception `error` throws,
/// or with the limit it ends the run with, [`crate::Realm::error_mark`] gives `mark`. A `catch`
/// clause that takes the exception drops the mark, so a `throw` of the caught value later
/// raises it without one; a `finally` block that ends normally keeps it.
Marked { error: Box<HostError>, mark: u32 },
}
/// A function the host gives a script, with its arguments converted.
@@ -104,6 +109,12 @@ pub enum HostItem {
Converted(Vec<Arg>, HostFunction),
/// A property whose value the function gives each time it is read.
Getter(HostFunction),
/// A property whose value `get` gives each time it is read, and that `set` is given each value
/// assigned to it, as its one argument; an error from `set` is thrown to the script.
Property {
get: HostFunction,
set: HostFunction,
},
/// A frozen object with these members.
Object(Vec<(String, HostItem)>),
/// A function whose host function gives JavaScript source as a string, which then runs as
@@ -111,11 +122,30 @@ pub enum HostItem {
/// the call gives that code's completion value, and its declarations become globals. A result
/// that is not a string is the call's result. `end` is called when the source has run,
/// normally or not. Each source given has the next tag of [`crate::Realm::error_source`],
/// from 1.
/// from 1. `strict_writes`, called after `source`, gives [`crate::Realm::set_strict_writes`]
/// for that source alone; with `None` it takes the calling code's.
Evaluator {
source: HostFunction,
end: Rc<dyn Fn()>,
strict_writes: Rc<dyn Fn() -> Option<bool>>,
},
/// A read-only property that reads the interpreter's own state.
Script(ScriptItem),
}
/// The interpreter's own state a host object can show.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ScriptItem {
/// The global object itself, with every global the script has made.
Global,
/// The last `Error` object thrown in the current run, caught or not, or `null` before one.
LastError,
/// The line of the script's code that reads it, counted from 1.
Line,
/// The running frames of the script's code, outermost first, one a line as `name:line`: the
/// global code is `[global]` and a function without a name `[anonymous]`. At most 64 frames
/// are listed, the innermost ones, after a line `…` when some are left out.
Stack,
}
enum Step {
@@ -343,17 +373,26 @@ impl Interp {
/// Calls evaluator `f` ([`HostItem::Evaluator`]): reads its arguments, runs it, and runs the
/// source it gives as global code, as an indirect `eval` does, giving that code's completion
/// value. A result that is not a string is given to the script as it is. `end` is called once
/// the source has run, normally or not.
pub(crate) fn call_evaluator(&mut self, f: &crate::heap::DataFn, end: &Rc<dyn Fn()>, name: &Rc<str>, args: &[Value]) -> JsResult<Value> {
/// the source has run, normally or not. `strict_writes` gives the source's
/// [`crate::bytecode::Source::strict_writes`], or `None` for the calling code's.
pub(crate) fn call_evaluator(
&mut self,
f: &crate::heap::DataFn,
end: &Rc<dyn Fn()>,
strict_writes: &Rc<dyn Fn() -> Option<bool>>,
name: &Rc<str>,
args: &[Value],
) -> JsResult<Value> {
let data = self.read_args(name, args, &ArgRead::Data)?;
let source = match f(data) {
Ok(Data::String(source)) => source,
Ok(d) => return self.write_data(d, 0, false),
Err(e) => return Err(self.host_error(e)?),
};
let writes = strict_writes().unwrap_or_else(|| self.writes_mode());
self.evaluated = self.evaluated.saturating_add(1);
let tag = std::mem::replace(&mut self.source_tag, self.evaluated);
let code = self.encode_source(&source).and_then(|units| self.compile_source(&units, false, crate::bytecode::CodeKind::Eval));
let code = self.encode_source(&source).and_then(|units| self.compile_source_writes(&units, false, writes, crate::bytecode::CodeKind::Eval));
self.source_tag = tag;
let r = match code {
Ok(code) => self.run_global(code, true),
@@ -416,6 +455,11 @@ impl Interp {
HostError::Type(message) => self.throw_host(ErrorType::Type, &message),
HostError::Range(message) => self.throw_host(ErrorType::Range, &message),
HostError::Syntax(message) => self.throw_host(ErrorType::Syntax, &message),
HostError::Marked { error, mark } => {
let abrupt = self.host_error(*error)?;
self.thrown_mark = Some(mark);
Ok(abrupt)
}
}
}
@@ -463,17 +507,30 @@ impl Interp {
let prop = match item {
HostItem::Value(d) => Prop::Data { value: self.write_data(d, 0, true)?, flags: ENUMERABLE },
HostItem::Getter(f) => Prop::Accessor { get: Some(self.host_function(f, &full, ArgRead::Data)), set: None, flags: ENUMERABLE },
HostItem::Property { get, set } => Prop::Accessor {
get: Some(self.host_function(get, &full, ArgRead::Data)),
set: Some(self.host_function(set, &full, ArgRead::Data)),
flags: ENUMERABLE,
},
HostItem::Object(items) => Prop::Data { value: Value::Object(self.host_object(&full, items)?), flags: ENUMERABLE },
HostItem::Function(f) => Prop::Data { value: Value::Object(self.host_function(f, &full, ArgRead::Data)), flags: ENUMERABLE },
HostItem::TextFunction(f) => Prop::Data { value: Value::Object(self.host_function(f, &full, ArgRead::Text)), flags: ENUMERABLE },
HostItem::Converted(list, f) => {
Prop::Data { value: Value::Object(self.host_function(f, &full, ArgRead::Each(list.into()))), flags: ENUMERABLE }
}
HostItem::Evaluator { source, end } => {
HostItem::Evaluator { source, end, strict_writes } => {
let proto = self.intrinsics.function_proto;
let f = self.heap.alloc_object(Object::new(Class::Function(Func::Eval { f: source, end, name: full.into() }), Some(proto)));
let func = Func::Eval { f: source, end, strict_writes, name: full.into() };
let f = self.heap.alloc_object(Object::new(Class::Function(func), Some(proto)));
Prop::Data { value: Value::Object(f), flags: ENUMERABLE }
}
HostItem::Script(item) => match crate::builtins::extensions::getter(item) {
None => Prop::Data { value: Value::Object(self.global), flags: ENUMERABLE },
Some((name, f)) => {
let get = crate::builtins::native(self, name, f, 0, crate::heap::NativeKind::Plain, false)?;
Prop::Accessor { get: Some(get), set: None, flags: ENUMERABLE }
}
},
};
self.define(o, key(&name), prop)
}
+53
View File
@@ -260,6 +260,59 @@ fn errors_from_the_host_can_be_caught_and_an_abort_cannot() {
assert_eq!((e.kind, e.message.as_str()), (RunErrorKind::Limit(LimitKind::Host), "stopped by the host"));
}
#[test]
fn a_marked_host_error_keeps_its_mark_only_while_no_catch_takes_it() {
let mut r = realm(1_000_000);
let f: HostFunction = Rc::new(|a| {
let error = match a.first() {
Some(Data::String(k)) if k == "abort" => HostError::Abort("stopped".into()),
_ => HostError::Syntax("bad file".into()),
};
Err(HostError::Marked { error: Box::new(error), mark: 7 })
});
r.define_host_object("host", vec![("f".into(), HostItem::Function(f))]).unwrap();
let cases = [
("host.f();", Some(7)),
("try { host.f(); } finally { var x = 1; }", Some(7)),
("try { try { host.f(); } finally { 1; } } finally { 2; }", Some(7)),
("function g() { host.f(); }\ng();", Some(7)),
("try { host.f(); } finally { try { try { host.f(); } finally { 1; } } catch (e) {} }", Some(7)),
("var saved; try { host.f(); } catch (e) { saved = e; }\nthrow saved;", None),
("try { host.f(); } catch (e) { throw e; }", None),
("try { try { host.f(); } finally { 1; } } catch (e) { throw e; }", None),
("var m; try { host.f(); } catch (e) { m = e.message; }\nthrow new SyntaxError(m);", None),
("do { try { host.f(); } finally { break; } } while (false);\nthrow new Error('other');", None),
("try { host.f('abort'); } catch (e) { 'caught' }", Some(7)),
];
for (source, mark) in cases {
assert!(r.run(source).is_err(), "{source}");
assert_eq!(r.error_mark(), mark, "{source}");
}
assert_eq!(r.run("1").unwrap(), JsValue::Number(1.0));
assert_eq!(r.error_mark(), None, "a later run that ends normally has no mark");
}
/// Marked errors that `finally` blocks discard with `return`, `break` or `continue` are collected
/// with the rest of the garbage, so a run that discards many of them stays within its heap.
#[test]
fn marked_errors_a_finally_block_discards_are_collected() {
let mut r = Realm::new();
r.set_limits(Limits { fuel: 200_000_000, heap_bytes: 1 << 20, ..Limits::default() });
let f: HostFunction = Rc::new(|_| Err(HostError::Marked { error: Box::new(HostError::Syntax("bad file".into())), mark: 7 }));
r.define_host_object("host", vec![("f".into(), HostItem::Function(f))]).unwrap();
let cases = [
"function d() { try { host.f(); } finally { return; } }\nfor (var i = 0; i < 20000; i++) d();",
"for (var i = 0; i < 20000; i++) { try { host.f(); } finally { continue; } }",
"for (var i = 0; i < 20000; i++) { do { try { host.f(); } finally { break; } } while (false); }",
];
for source in cases {
r.run(source).unwrap_or_else(|e| panic!("{source}: {e:?}"));
assert_eq!(r.error_mark(), None, "{source}");
}
assert!(r.run("try { host.f(); } finally { 1; }").is_err());
assert_eq!(r.error_mark(), Some(7));
}
#[test]
fn an_error_says_where_in_the_source_it_was_raised() {
let mut r = realm(1_000_000);
+102 -8
View File
@@ -164,6 +164,13 @@ pub(crate) struct Interp {
pub(crate) throw_site: Option<ThrowSite>,
/// The last exception a run did not catch.
pub(crate) last_thrown: Value,
/// The last `Error` object thrown in this run, caught or not, or `null` (`$.error`).
pub(crate) last_error: Value,
/// An exception passed on rather than thrown again: one a `finally` block restores, or one
/// leaving a nested run. [`Interp::unwind`] does not make it [`Interp::last_error`] again.
pub(crate) passing: Value,
/// The locale `localize` and `Object.prototype.toString` read.
pub(crate) localization: Rc<crate::builtins::extensions::Localization>,
/// The state of `Math.random`'s generator.
pub(crate) random: u64,
/// The objects `Array.prototype.join` and `toLocaleString` are joining, innermost last: a
@@ -197,6 +204,18 @@ pub(crate) struct Interp {
pub(crate) evaluated: u32,
/// The tag of the source the last run's error was raised in.
pub(crate) error_tag: u32,
/// The mark of the host error ([`crate::HostError::Marked`]) whose exception or limit is
/// propagating.
pub(crate) thrown_mark: Option<u32>,
/// The marked exceptions `finally` blocks are running for, innermost last, each with its mark,
/// which the block's end throws it on with, and the stack slot that holds it while the block
/// runs. An entry whose slot no longer holds its exception belongs to a block that `break`,
/// `continue` or `return` left, and [`Self::drop_left_finally_marks`] removes it.
pub(crate) finally_marks: Vec<(ObjId, u32, usize)>,
/// The mark of the host error the last run ended with.
pub(crate) error_mark: Option<u32>,
/// [`crate::bytecode::Source::strict_writes`] for the scripts the realm runs.
pub(crate) strict_writes: bool,
}
/// The bytes all live [`HeldBytes`] hold, and the bytes dropped holders released that the heap's
@@ -379,6 +398,9 @@ impl Interp {
source_units: 0,
throw_site: None,
last_thrown: Value::Undefined,
last_error: Value::Null,
passing: Value::Undefined,
localization: Rc::default(),
random: random_seed(),
join_stack: Vec::new(),
held_bytes: 0,
@@ -394,6 +416,10 @@ impl Interp {
source_tag: crate::realm::UNKNOWN_SOURCE,
evaluated: 0,
error_tag: crate::realm::UNKNOWN_SOURCE,
thrown_mark: None,
finally_marks: Vec::new(),
error_mark: None,
strict_writes: false,
};
crate::builtins::install(&mut interp);
interp.fuel = limits.fuel;
@@ -547,11 +573,22 @@ impl Interp {
Ok(())
}
/// Removes the [`Self::finally_marks`] entries of `finally` blocks that `break`, `continue` or
/// `return` left: those whose stack slot no longer holds their exception.
fn drop_left_finally_marks(&mut self) {
let stack = &self.stack;
self.finally_marks.retain(|&(id, _, slot)| matches!(stack.get(slot), Some(Value::Object(o)) if *o == id));
}
pub(crate) fn collect_garbage(&mut self) {
self.drop_left_finally_marks();
let mut roots = Roots::default();
roots.values.extend(self.stack.iter());
roots.values.extend(self.temp_roots.iter());
roots.values.push(&self.last_thrown);
roots.values.push(&self.last_error);
roots.values.push(&self.passing);
roots.objects.extend(self.finally_marks.iter().map(|&(id, _, _)| id));
for f in &self.frames {
roots.values.push(&f.this);
roots.values.push(&f.completion);
@@ -637,9 +674,18 @@ impl Interp {
/// boundary frame. Returns the exception when no handler takes it before the boundary frame.
fn unwind(&mut self, v: Value) -> JsResult<()> {
self.note_site();
let passed = matches!((&v, std::mem::replace(&mut self.passing, Value::Undefined)), (Value::Object(a), Value::Object(b)) if *a == b);
if !passed
&& let Value::Object(o) = v
&& self.heap.object(o).is_some_and(|o| matches!(o.class, Class::Error))
{
self.last_error = v.clone();
}
loop {
let Some(frame) = self.frames.last_mut() else { return Err(Abrupt::Throw(v)) };
if let Some(h) = frame.code.handler(frame.pc.saturating_sub(1)) {
// A catch clause starts with `PushCatch`; every other handler is a `finally` block's.
let caught = matches!(frame.code.ops.get(h.target as usize), Some(Op::PushCatch(_)));
let target_len = frame.base + h.depth as usize;
while frame.env_depth > h.env_depth {
let outer = self.heap.env(frame.env).and_then(Env::outer);
@@ -652,6 +698,10 @@ impl Interp {
return internal("handler stack deeper than the stack");
}
self.stack.truncate(target_len);
self.drop_left_finally_marks();
if let (Some(mark), false, Value::Object(id)) = (self.thrown_mark.take(), caught, &v) {
self.finally_marks.push((*id, mark, target_len));
}
self.stack.push(v);
self.throw_site = None;
return Ok(());
@@ -659,11 +709,18 @@ impl Interp {
let Some(frame) = self.frames.pop() else { return Err(Abrupt::Throw(v)) };
self.stack.truncate(frame.base);
if frame.boundary {
self.passing = v.clone();
return Err(Abrupt::Throw(v));
}
}
}
/// Each running frame of script code, outermost first: its code, the index of its next
/// instruction, and whether it runs a function (not global or eval code).
pub(crate) fn script_frames(&self) -> impl Iterator<Item = (&Rc<Code>, usize, bool)> {
self.frames.iter().map(|f| (&f.code, f.pc, f.callee.is_some()))
}
fn abort_to_boundary(&mut self) {
while let Some(frame) = self.frames.pop() {
self.stack.truncate(frame.base);
@@ -700,6 +757,18 @@ impl Interp {
self.frames.last().is_some_and(|f| f.code.strict)
}
/// Does a property write the running code makes throw when it cannot be made? It does in
/// strict mode code and in code from a source with [`crate::bytecode::Source::strict_writes`].
fn strict_writes(&self) -> bool {
self.frames.last().is_some_and(|f| f.code.strict || f.code.source.strict_writes)
}
/// [`crate::bytecode::Source::strict_writes`] for source compiled now: the running code's,
/// or the realm's between runs.
pub(crate) fn writes_mode(&self) -> bool {
self.frames.last().map_or(self.strict_writes, |f| f.code.source.strict_writes)
}
fn exec(&mut self, op: Op) -> JsResult<()> {
match op {
Op::Undefined => self.push(Value::Undefined),
@@ -753,7 +822,10 @@ impl Interp {
Op::Jump(t) => self.jump_to(t),
Op::JumpIfFalse(t) | Op::JumpIfTrue(t) => self.op_branch(t, matches!(op, Op::JumpIfTrue(_)), false),
Op::JumpIfFalseKeep(t) | Op::JumpIfTrueKeep(t) => self.op_branch(t, matches!(op, Op::JumpIfTrueKeep(_)), true),
Op::Throw => Err(Abrupt::Throw(self.pop()?)),
Op::Throw => {
self.thrown_mark = None;
Err(Abrupt::Throw(self.pop()?))
}
Op::ThrowReferenceError(i) => self.op_throw_reference_error(i),
Op::EndFinally(t) => self.op_end_finally(t),
Op::NewObject => self.op_new_object(),
@@ -835,7 +907,15 @@ impl Interp {
return self.pop().map(drop);
}
if kind == 1.0 {
return Err(Abrupt::Throw(self.pop()?));
let v = self.pop()?;
self.passing = v.clone();
if let Value::Object(o) = v
&& let Some(i) = self.finally_marks.iter().rposition(|&(id, _, _)| id == o)
{
self.thrown_mark = self.finally_marks.get(i).map(|&(_, mark, _)| mark);
self.finally_marks.truncate(i);
}
return Err(Abrupt::Throw(v));
}
let k = (kind - 2.0) as usize;
let target = self.frame()?.code.finally_tables.get(table as usize).and_then(|t| t.get(k)).copied();
@@ -918,11 +998,14 @@ impl Interp {
_ => Ok(()),
}
}
Resolved::Obj { object, .. } => self.put(object, name.clone(), v, strict),
Resolved::Obj { object, .. } => {
let throw = self.strict_writes();
self.put(object, name.clone(), v, throw)
}
Resolved::Unresolved if strict => Err(self.not_defined(name)),
Resolved::Unresolved => {
let global = self.global;
self.put(global, name.clone(), v, false)
let (global, throw) = (self.global, self.strict_writes());
self.put(global, name.clone(), v, throw)
}
}
}
@@ -1453,7 +1536,10 @@ impl Interp {
fn put_value(&mut self, base: &Value, k: Key, v: Value) -> JsResult<()> {
let strict = self.strict();
match base {
Value::Object(o) => self.put(*o, k, v, strict),
Value::Object(o) => {
let throw = self.strict_writes();
self.put(*o, k, v, throw)
}
Value::Undefined | Value::Null => {
let message = format!(
"cannot set property '{}' of {}",
@@ -1869,7 +1955,9 @@ impl Interp {
// keeps itself reachable.
return Ok(Prepared::Done(self.call_data(&f, &name, &args, &read)?));
}
Func::Eval { f, end, name } => return Ok(Prepared::Done(self.call_evaluator(&f, &end, &name, &args)?)),
Func::Eval { f, end, strict_writes, name } => {
return Ok(Prepared::Done(self.call_evaluator(&f, &end, &strict_writes, &name, &args)?));
}
Func::Host { f: host, .. } => {
let js_args: Vec<crate::JsValue> = args.iter().map(|v| self.to_js_value(v)).collect();
return match host(&js_args) {
@@ -2138,11 +2226,17 @@ impl Interp {
/// Parses and compiles source for eval code or a script, counting it against the source
/// limit.
pub(crate) fn compile_source(&mut self, units: &[u16], strict: bool, kind: CodeKind) -> Result<Rc<Code>, CompileError> {
let writes = self.writes_mode();
self.compile_source_writes(units, strict, writes, kind)
}
/// [`Interp::compile_source`], with [`crate::bytecode::Source::strict_writes`] set to `writes`.
pub(crate) fn compile_source_writes(&mut self, units: &[u16], strict: bool, writes: bool, kind: CodeKind) -> Result<Rc<Code>, CompileError> {
self.source_units = self.source_units.saturating_add(units.len());
let remaining = self.options.max_source_units.saturating_sub(self.source_units.saturating_sub(units.len()));
let options = Options { strict, max_source_units: remaining, ..self.options.clone() };
let program = crate::parse_utf16(units, &options).map_err(CompileError::Syntax)?;
let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: self.source_tag });
let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: self.source_tag, strict_writes: writes });
compile_program(&program, &source, kind).map_err(|e| match e {
CompileFail::Syntax(s) => CompileError::Syntax(s),
CompileFail::Internal(m) => CompileError::Internal(m),
+57 -1
View File
@@ -691,6 +691,61 @@ fn a_strict_realm_runs_scripts_as_strict_mode_code() {
assert_eq!(realm.run("(function () { return typeof this; })()"), Ok(JsValue::String("object".into())));
}
#[test]
fn strict_writes_are_separate_from_es5_strict() {
let setup = "var ro = {}; Object.defineProperty(ro, 'x', {value: 1}); \
var getter = {get g() { return 1; }}; var shut = Object.preventExtensions({a: 1}); var open = {};";
let mut realm = Realm::new();
realm.set_strict_writes(true);
realm.run(setup).unwrap();
for (src, why) in [
("ro.x = 2", "cannot assign to property 'x': it is read-only"),
("ro['x'] += 1", "cannot assign to property 'x': it is read-only"),
("ro.x++", "cannot assign to property 'x': it is read-only"),
("getter.g = 2", "cannot assign to property 'g': it has only a getter"),
("shut.b = 2", "cannot assign to property 'b': the object is not extensible"),
("Object.create(ro).x = 2", "cannot assign to property 'x': it is read-only"),
("NaN = 1", "cannot assign to property 'NaN': it is read-only"),
("(function () { ro.x = 2; })()", "cannot assign to property 'x': it is read-only"),
("eval('ro.x = 2')", "cannot assign to property 'x': it is read-only"),
("(0, eval)('ro.x = 2')", "cannot assign to property 'x': it is read-only"),
("Function('ro.x = 2')()", "cannot assign to property 'x': it is read-only"),
] {
let e = realm.run(src).unwrap_err();
assert_eq!(e.kind, RunErrorKind::Thrown, "{src}");
assert!(e.message.contains("TypeError") && e.message.contains(why), "{src}: {}", e.message);
}
// Writes that can be made, and the rest of strict mode, are as before.
assert_eq!(realm.run("open.n = 1; shut.a = 5; open.n + shut.a"), Ok(JsValue::Number(6.0)));
assert_eq!(realm.run("with ({w: 2}) { w } "), Ok(JsValue::Number(2.0)));
assert_eq!(realm.run("(function (a, a) { return a; })(1, 2)"), Ok(JsValue::Number(2.0)));
assert_eq!(realm.run("010"), Ok(JsValue::Number(8.0)));
assert_eq!(realm.run("undeclared = 3; undeclared"), Ok(JsValue::Number(3.0)));
assert_eq!(realm.run("(function () { return typeof this; })()"), Ok(JsValue::String("object".into())));
assert_eq!(realm.run("delete Object.prototype"), Ok(JsValue::Bool(false)));
assert_eq!(realm.run("'abc'.length = 1; 'abc'.extra = 1; 1"), Ok(JsValue::Number(1.0)), "a primitive's properties as in sloppy mode");
// 'use strict' keeps all of ES5 strict mode.
assert_eq!(realm.run("'use strict'; with ({}) {}").map_err(|e| e.kind), Err(RunErrorKind::Syntax));
assert_eq!(realm.run("'use strict'; delete Object.prototype").map_err(|e| e.kind), Err(RunErrorKind::Thrown));
// Off, the same writes are ignored, as in sloppy mode code.
realm.set_strict_writes(false);
assert_eq!(realm.run("ro.x = 2; getter.g = 2; shut.b = 2; NaN = 1; ro.x + getter.g + String(shut.b)"), Ok(JsValue::String("2undefined".into())));
// A function compiled with the writes on keeps them when called from code without.
let mut realm = Realm::new();
realm.set_strict_writes(true);
realm.run(&format!("{setup} function w() {{ ro.x = 2; }}")).unwrap();
realm.set_strict_writes(false);
assert!(realm.run("w()").unwrap_err().message.contains("it is read-only"));
assert_eq!(realm.run("ro.x = 3; ro.x"), Ok(JsValue::Number(1.0)));
// An undeclared name added to a global object that is not extensible.
let mut realm = Realm::new();
realm.set_strict_writes(true);
let e = realm.run("Object.preventExtensions(this); newName = 1").unwrap_err();
assert!(e.message.contains("cannot assign to property 'newName': the object is not extensible"), "{}", e.message);
let mut realm = Realm::new();
assert_eq!(realm.run("Object.preventExtensions(this); newName = 1; typeof newName"), Ok(JsValue::String("undefined".into())));
}
#[test]
fn an_apply_that_throws_while_reading_its_arguments_keeps_nothing_alive() {
// Each call reads a fresh array at index 0 before index 1 throws; the arrays must not stay
@@ -774,7 +829,7 @@ fn deep_nesting_compiles_on_a_small_stack() {
// ---- the verifier ----
fn code(ops: Vec<Op>) -> Code {
let source = Rc::new(Source { lines: crate::LineTable::new(&[]), text: Rc::from(Vec::<u16>::new()), tag: 0 });
let source = Rc::new(Source { lines: crate::LineTable::new(&[]), text: Rc::from(Vec::<u16>::new()), tag: 0, strict_writes: false });
Code {
positions: vec![0; ops.len()],
ops,
@@ -788,6 +843,7 @@ fn code(ops: Vec<Op>) -> Code {
max_stack: 0,
strict: false,
self_name: None,
decl_name: None,
params: Vec::new(),
vars: Vec::new(),
fun_decls: Vec::new(),
+4 -1
View File
@@ -65,8 +65,9 @@ pub use ast::{
AssignOp, Ast, BinaryOp, CatchClause, Expr, ExprId, ForInTarget, ForInit, Function, FunctionId, FunctionKind, LogicalOp, Name, Program, Property,
PropertyKey, PropertyValue, Stmt, StmtId, SwitchCase, UnaryOp, UpdateOp, VarDecl,
};
pub use builtins::extensions::Localization;
pub use error::{SyntaxError, TOO_DEEP, TOO_LONG};
pub use host::{Arg, Data, DataLimits, HostError, HostFunction, HostItem};
pub use host::{Arg, Data, DataLimits, HostError, HostFunction, HostItem, ScriptItem};
pub use lexer::{Goal, Lexer};
pub use number::to_string as number_to_string;
pub use realm::{Interrupt, JsValue, LimitKind, Limits, MAX_UNBROKEN_FUEL, Progress, Realm, RunError, RunErrorKind, UNKNOWN_SOURCE};
@@ -187,6 +188,8 @@ impl LineTable {
#[cfg(test)]
mod builtins_tests;
#[cfg(test)]
mod extension_tests;
#[cfg(test)]
mod host_tests;
#[cfg(test)]
mod interp_tests;
+37
View File
@@ -174,6 +174,14 @@ impl Realm {
self.unbroken_fuel = fuel;
}
/// With `on`, the scripts run next (and the code they define and evaluate) throw a
/// `TypeError` when they assign to a read-only or getter-only property or add a property to
/// an object that is not extensible, as strict mode code does, without the rest of strict
/// mode. Off by default.
pub fn set_strict_writes(&mut self, on: bool) {
self.interp.strict_writes = on;
}
pub fn limits(&self) -> &Limits {
&self.interp.limits
}
@@ -221,9 +229,13 @@ impl Realm {
interp.source_units = 0;
interp.throw_site = None;
interp.last_thrown = Value::Undefined;
interp.last_error = Value::Null;
interp.host_abort = None;
interp.waiting = false;
interp.error_tag = UNKNOWN_SOURCE;
interp.thrown_mark = None;
interp.finally_marks.clear();
interp.error_mark = None;
let strict = interp.options.strict;
interp.source_tag = 0;
let code = interp.encode_source(source).and_then(|units| interp.compile_source(&units, strict, CodeKind::Global));
@@ -244,11 +256,14 @@ impl Realm {
let site = interp.throw_site.take();
interp.error_tag = site.as_ref().map_or(UNKNOWN_SOURCE, |(code, _)| code.source.tag);
let (line, column) = site.map_or((0, 0), |(code, pc)| code.line_column(pc));
interp.error_mark = interp.thrown_mark.take().filter(|_| matches!(result, Err(Abrupt::Throw(_) | Abrupt::Limit(LimitKind::Host))));
interp.finally_marks.clear();
interp.stack.clear();
interp.frames.clear();
interp.temp_roots.clear();
interp.join_stack.clear();
interp.waiting = false;
interp.passing = Value::Undefined;
match result {
Ok(v) => Ok(interp.to_js_value(&v)),
Err(Abrupt::Throw(v)) => {
@@ -359,6 +374,12 @@ impl Realm {
self.interp.error_tag
}
/// The mark of the host error ([`crate::HostError::Marked`]) the last run ended with, when no
/// `catch` clause took that error's exception on its way out; `None` otherwise.
pub fn error_mark(&self) -> Option<u32> {
self.interp.error_mark
}
/// Fuel the run has left.
pub fn fuel_left(&self) -> u64 {
self.interp.fuel
@@ -379,6 +400,17 @@ impl Realm {
}
}
/// Adds the script extensions: the global `localize(object, ...values)`, which gives a
/// localization object's text for the locale of [`Realm::localization`], the global
/// `toSource(value)` and `Object.prototype.toSource()`, which write plain data as source text,
/// and the localized `Object.prototype.toString` while [`crate::Localization::enabled`].
pub fn add_script_extensions(&mut self) -> Result<(), RunError> {
match crate::builtins::extensions::install(&mut self.interp) {
Ok(()) => Ok(()),
Err(e) => self.outcome(Err(e)).map(drop),
}
}
/// Defines the global function `name`, whose arguments arrive converted as `args` says
/// ([`crate::HostItem::Converted`]). Unlike a host object, a script may replace or delete it, as
/// it may a built-in function.
@@ -389,6 +421,11 @@ impl Realm {
}
}
/// The locale `localize` reads, which the host's functions may change during a run.
pub fn localization(&self) -> std::rc::Rc<crate::Localization> {
self.interp.localization.clone()
}
/// Defines the global variable `name` holding the exception the last run did not catch
/// (`undefined` if there was none).
pub fn bind_last_thrown(&mut self, name: &str) {
+4
View File
@@ -22,6 +22,10 @@ craft-vectorcraft-format = { workspace = true }
sha2 = { version = "0.10", default-features = false }
thiserror = { workspace = true }
wasmi = { workspace = true }
# The monotonic clock of run deadlines and the first-view budget: `std::time::Instant` on the
# desktop, the page's `performance.now()` in the web build. It is MIT OR Apache-2.0 and already in
# the build through eframe.
web-time = "1"
[dev-dependencies]
wat = { workspace = true }
+14 -11
View File
@@ -86,20 +86,15 @@ pub struct Refreshed {
pub failed: usize,
}
/// Milliseconds on a monotonic clock, `None` where there is none (the web build).
/// Milliseconds on a monotonic clock, `None` from a clock that cannot be read.
pub type Clock = Arc<dyn Fn() -> Option<u64> + Send + Sync>;
/// Milliseconds since the first reading in this process (native builds); `None` on the web build,
/// whose `std` has no monotonic clock.
/// Milliseconds since the first reading in this process, from `web_time::Instant`: the system's
/// monotonic clock on the desktop and the page's `performance.now()` in the web build.
fn monotonic() -> Option<u64> {
#[cfg(not(target_arch = "wasm32"))]
{
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
let start = START.get_or_init(std::time::Instant::now);
Some(u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX))
}
#[cfg(target_arch = "wasm32")]
None
static START: std::sync::OnceLock<web_time::Instant> = std::sync::OnceLock::new();
let start = START.get_or_init(web_time::Instant::now);
Some(u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX))
}
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -579,6 +574,14 @@ mod tests {
});
}
/// The default clock, which the web build also reads, gives the first view its budget.
#[test]
fn the_default_clock_gives_the_first_view_a_budget() {
let d = Deferrals::new();
assert!(matches!(d.allowance(), Allowance::Until { ms: OPENING_BUDGET_MS, .. }));
assert!(!d.passed(u64::MAX));
}
/// A stage drawn with several inputs (the paths of a group) holds one record for each, and is
/// listed and counted once.
#[test]
+1 -2
View File
@@ -24,8 +24,7 @@ use crate::{Error, Kind, Limits, Plugin, Result, registry};
/// document: 200 times a filter's base budget.
pub const FUEL: u64 = 10_000_000_000;
/// Wall-clock budget of one export, in milliseconds (native builds; the web build has no clock,
/// and the instruction budget alone bounds it there).
/// Wall-clock budget of one export, in milliseconds.
pub const WALL_TIME_MS: u64 = 300_000;
/// Most warnings an export reports.
+14 -14
View File
@@ -321,29 +321,20 @@ pub struct Report {
/// The limit a run past its wall-clock deadline exceeded ([`Error::Limit`]).
pub(crate) const TIME_BUDGET: &str = "time budget";
/// When a run must stop (native: a wall-clock deadline).
/// When a run must stop: a deadline on the monotonic clock (`web_time::Instant`, the page's
/// `performance.now()` in the web build).
#[derive(Clone, Copy)]
pub(crate) struct Deadline {
#[cfg(not(target_arch = "wasm32"))]
at: std::time::Instant,
at: web_time::Instant,
}
impl Deadline {
pub(crate) fn new(wall_time_ms: u64) -> Self {
#[cfg(target_arch = "wasm32")]
let _ = wall_time_ms;
Self {
#[cfg(not(target_arch = "wasm32"))]
at: std::time::Instant::now() + std::time::Duration::from_millis(wall_time_ms),
}
Self { at: web_time::Instant::now() + std::time::Duration::from_millis(wall_time_ms) }
}
pub(crate) fn passed(&self) -> bool {
#[cfg(not(target_arch = "wasm32"))]
if std::time::Instant::now() >= self.at {
return true;
}
false
web_time::Instant::now() >= self.at
}
pub(crate) fn check(&self) -> Result<()> {
@@ -816,6 +807,15 @@ mod tests {
Run::new("test", true, false, Deadline::new(60_000))
}
/// A deadline reads the monotonic clock that the web build also reads: one of 0 ms has
/// passed, and one a day away has not.
#[test]
fn a_deadline_passes_on_the_monotonic_clock() {
assert!(Deadline::new(0).passed());
assert!(matches!(Deadline::new(0).check(), Err(Error::Limit(l)) if l == TIME_BUDGET));
assert!(!Deadline::new(86_400_000).passed());
}
/// Progress is clamped: negative counts are 0 and `done` never passes a known total.
#[test]
fn progress_is_clamped() {
+1 -1
View File
@@ -9,7 +9,7 @@
//! permission): no WASI, file system, network, clock or randomness. A module that
//! imports anything else is rejected;
//! - an **instruction budget** (fuel) per call, metered in slices so a wall-clock deadline is
//! also enforced (native builds), so infinite loops end with an error. A filter may ask for a
//! also enforced, so infinite loops end with an error. A filter may ask for a
//! larger one ([`Budget::Heavy`]), which the host lets the user allow before installing it;
//! - a **linear-memory cap**, a **recursion-depth cap** and wasmi's strict module limits;
//! - a **fresh instance per run**, so no state leaks between calls; what a plug-in keeps is only
+3 -3
View File
@@ -29,7 +29,7 @@ const FUEL_COSTS: CustomFuelCosts = CustomFuelCosts { bytes_copied_per_fuel: 64,
/// the host learns that only once the run has ended.
pub const HEAVY_FUEL: u64 = 1_000_000_000;
/// Wall-clock budget of a heavy filter's run, in milliseconds (native builds).
/// Wall-clock budget of a heavy filter's run, in milliseconds.
pub const HEAVY_WALL_TIME_MS: u64 = 300_000;
/// The limit a run reports when its input and parameters together are past half its memory.
@@ -56,8 +56,8 @@ pub struct Limits {
pub fuel_per_byte: u64,
/// Deepest call nesting.
pub max_recursion_depth: usize,
/// Wall-clock budget for one run, in milliseconds. Native builds only: the web build has no
/// monotonic clock in `std`, so the fuel budget alone bounds it there.
/// Wall-clock budget for one run, in milliseconds. The web build measures it with the page's
/// `performance.now()`.
pub wall_time_ms: u64,
/// The most a heavy filter ([`crate::Budget::Heavy`]) is given instead of `fuel_base` and
/// `wall_time_ms`: what the host allows, which the module's manifest can ask for but never
@@ -156,8 +156,6 @@ impl<'s, 'a, 'l> Analyzer<'s, 'a, 'l> {
} else if n == catalog::DOLLAR_EVAL_FILE {
self.acc.feature(FeatureId::DollarEvalFile, line);
self.acc.outside(OutsideId::UnseenCode, line, None);
} else if n == catalog::LOCALIZE {
self.acc.feature(FeatureId::Localize, line);
} else {
self.acc.feature(FeatureId::Dollar, line);
}
+2
View File
@@ -16,6 +16,8 @@ pub(crate) const DOLLAR_EVAL_FILE: &str = "evalFile";
// https://extendscript.docsforadobe.dev/extendscript-tools-features/user-notification-dialogs/
pub(crate) const NOTIFY: &[&str] = &["alert", "confirm", "prompt"];
// The global function. `$.localize` is a property of `$` (the Dollar object page above), so it is
// a `$` member:
// https://extendscript.docsforadobe.dev/extendscript-tools-features/localizing-extendscript-strings/
pub(crate) const LOCALIZE: &str = "localize";
+10 -1
View File
@@ -35,6 +35,7 @@ const FEATURE_CASES: &[(&str, &str, &str)] = &[
("directive.target", " //@target acme", "x(); //@target acme"),
("directive.targetengine", "#targetengine session", "var targetengine = 'session';"),
("dollar", "$.writeln('hi');", "log.writeln('hi');"),
("dollar", "$.localize = true;", "obj.localize = true;"),
("dollar.env", "var home = $.getenv('HOME');", "var home = env.getenv('HOME');"),
("dollar.evalFile", "$.evalFile(f);", "loader.evalFile(f);"),
("dynamicCode", "eval(code);", "safe.eval(code);"),
@@ -68,7 +69,6 @@ const FEATURE_CASES: &[(&str, &str, &str)] = &[
("file.write", "Folder.temp.remove();", "cache.temp.remove();"),
("hostCheck", "if (parseInt(app.version) < 20) {}", "if (parseInt(appVersion) < 20) {}"),
("localize", "var s = localize(text);", "var s = text.localize;"),
("localize", "var s = $.localize(text);", "var s = obj.localize(text);"),
("menuCommand", "app.executeMenuCommand('acmecopyall');", "executeMenuCommand('acmecopyall');"),
("notify", "alert('done');", "panel.alert('done');"),
("notify", "if (confirm('Go?')) {}", "function confirm(m) {}"),
@@ -103,6 +103,15 @@ fn each_feature_is_found_where_it_is_and_nowhere_else() {
}
}
#[test]
fn the_dollar_objects_localize_property_is_not_the_global_localize_function() {
for src in ["$.localize = true;", "if ($.localize) {}", "var s = $.localize(text);"] {
let r = report(src);
assert!(feature(&r, "dollar").is_some(), "{src}: {:?}", r.features);
assert!(feature(&r, "localize").is_none(), "{src}: {:?}", r.features);
}
}
#[test]
fn features_are_listed_in_alphabetical_order_of_their_ids() {
let ids: Vec<&str> = FeatureId::ALL.iter().map(|f| f.id()).collect();
+21 -5
View File
@@ -502,10 +502,26 @@ pub fn param_keys(doc: &str) -> Vec<String> {
out
}
/// Parameter-name keys that name filesystem paths: fuzzing these with arbitrary strings could
/// write files anywhere, so callers substitute a temp path.
/// Parameter-name keys that name a file or folder a command may write: fuzzing these with
/// arbitrary strings could write files anywhere, so callers substitute a temp path.
pub fn is_path_key(k: &str) -> bool {
k == "path"
matches!(k, "path" | "diffPath") || is_folder_key(k)
}
/// Path keys that name a folder a command creates or writes files into.
pub fn is_folder_key(k: &str) -> bool {
matches!(k, "folder" | "destinationFolder")
}
/// The location to send under the path key `k`: `safe_path` for a file, a folder next to it for
/// a folder key.
pub fn safe_location(k: &str, safe_path: &str) -> String {
if is_folder_key(k) { format!("{safe_path}-folder") } else { safe_path.to_string() }
}
/// `v`, or the temp location for `k` when `k` is a path key and `v` is a string.
pub fn safe_value(k: &str, v: &Value, safe_path: &str) -> Value {
if is_path_key(k) && v.is_string() { json!(safe_location(k, safe_path)) } else { v.clone() }
}
/// A deterministic list of junk param objects for `doc` (the command's params doc): each
@@ -516,7 +532,7 @@ pub fn junk_params(doc: &str, safe_path: &str) -> Vec<Value> {
let mut out = vec![Value::Null, json!([]), json!("str"), json!(42), json!({"unknownKey": 1})];
for k in &keys {
for (i, v) in junk.iter().enumerate() {
let v = if is_path_key(k) && v.is_string() { json!(safe_path) } else { v.clone() };
let v = safe_value(k, v, safe_path);
// Sparse: one key at a time with every junk value, plus a few combos.
let mut o = serde_json::Map::new();
o.insert(k.clone(), v.clone());
@@ -524,7 +540,7 @@ pub fn junk_params(doc: &str, safe_path: &str) -> Vec<Value> {
if i % 5 == 0 {
let mut all = serde_json::Map::new();
for k2 in &keys {
let v2 = if is_path_key(k2) { json!(safe_path) } else { v.clone() };
let v2 = if is_path_key(k2) { json!(safe_location(k2, safe_path)) } else { v.clone() };
all.insert(k2.clone(), v2);
}
out.push(Value::Object(all));
+2 -2
View File
@@ -548,8 +548,8 @@ impl Tool for SmartPenTool {
}
/// A path being drawn: Escape ends it.
fn ends_on_escape(&self, _cx: &ToolContext) -> bool {
self.path.is_some()
fn ends_on_escape(&self, cx: &ToolContext) -> bool {
self.active(cx).is_some()
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
+4 -2
View File
@@ -782,8 +782,10 @@ impl Tool for GestureTool {
};
let color = if self.id == "eraser" { [0x80, 0x80, 0x80] } else { FEEDBACK };
// A tip that is not round shows the outline of the area it covers, each step of the drag
// as wide as the pen pressed there with Size set to Pressure.
if matches!(self.id, "eraser" | "blobBrush") && self.roundness < 100.0 && !self.smoothing {
// as wide as the pen pressed there with Size set to Pressure. So does a round Blob Brush
// tip: a line as wide as the brush would end square, where the stroke it adds is round at
// both ends (#4343).
if matches!(self.id, "eraser" | "blobBrush") && (self.roundness < 100.0 || self.id == "blobBrush") && !self.smoothing {
let area = |pts: &[Point], size| {
if self.id == "blobBrush" {
blob_area(pts, size, self.angle, self.roundness)
+30
View File
@@ -382,6 +382,36 @@ fn blob_brush_drag_shows_scrub_length_measurement_labels() {
assert!(!quiet.overlays(&off).iter().any(|o| matches!(o, Overlay::Measure { .. })));
}
/// #4343: while it is dragged, a round Blob Brush shows the outline of the area `path.blob` adds,
/// which reaches half the brush beyond the ends of the stroke (round tips), not a line as wide as
/// the brush that ends square at them.
#[test]
fn blob_brush_preview_is_the_outline_of_the_area_with_round_tips() {
use vectorcraft_geom::kurbo::Shape;
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = GestureTool::new("blobBrush");
t.size = 20.0;
assert_eq!(t.roundness, 100.0, "a round tip by default");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 300.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 300.0));
let outlines: Vec<_> =
t.overlays(&cx).into_iter().filter_map(|o| if let Overlay::Path { path, width, .. } = o { Some((path, width)) } else { None }).collect();
assert!(outlines.iter().all(|(_, width)| f64::from(*width) < t.size / 2.0), "no line as wide as the brush: {outlines:?}");
// The stroke's outline is the widest one (the tip at the pointer is another).
let Some((path, _)) = outlines.iter().max_by(|a, b| a.0.bounding_box().width().total_cmp(&b.0.bounding_box().width())) else {
panic!("no outline")
};
let b = path.bounding_box();
assert!((b.x0 - 40.0).abs() < 0.5 && (b.x1 - 160.0).abs() < 0.5, "round tips reach half the size past the ends: {b:?}");
assert!((b.y0 - 290.0).abs() < 0.5 && (b.y1 - 310.0).abs() < 0.5, "{b:?}");
// Round, not square: the corner of the box a square-ended line would fill is outside the area.
assert!(!path.contains(vectorcraft_geom::Point::new(41.0, 291.0)));
assert!(path.contains(vectorcraft_geom::Point::new(45.0, 300.0)));
}
/// The Blob Brush has Keep selected and Merge only with selection, both off by default, and its
/// stroke sends both.
#[test]
+41
View File
@@ -1194,6 +1194,47 @@ impl Targets {
(d, ov, held.contains(&true))
}
/// [`Self::snap_moved_to_rulers`] for a selection a move Shift keeps to its line (through the
/// grabbed point `p`, along `dir`), its bounds now `r`: the edge or centre of `r` that a ruler
/// guide runs past within `tol` goes onto it by sliding along the line, the way needing the
/// shortest slide winning and at most twice `tol` (a line nearly parallel to a guide would
/// carry the selection far to meet it). Returns the shift along the line and the guides.
pub(crate) fn snap_moved_along_to_rulers(&self, p: Point, r: Rect, dir: Vec2, tol: f64) -> (Vec2, Vec<Overlay>) {
let len = dir.hypot();
if !len.is_finite() || len <= 1e-9 {
return (Vec2::ZERO, vec![]);
}
let u = dir / len;
// The way along the line, where the guide is and where it starts, and whether it is upright.
let mut best: Option<(f64, f64, Point, bool)> = None;
for (vertical, c) in [(true, u.x), (false, u.y)] {
if c.abs() <= 1e-9 {
continue;
}
let vs = if vertical { [r.x0, r.center().x, r.x1] } else { [r.y0, r.center().y, r.y1] };
let (mid, half) =
if vertical { (Point::new(0.0, r.center().y), r.height() / 2.0) } else { (Point::new(r.center().x, 0.0), r.width() / 2.0) };
for (t, from, _) in self.rulers.iter().filter(|g| g.vertical == vertical).filter_map(|g| g.line_at(mid, tol + half)) {
for v in vs {
let s = (t - v) / c;
if (t - v).abs() <= tol && s.abs() <= 2.0 * tol && best.is_none_or(|b| s.abs() < b.0.abs()) {
best = Some((s, t, from, vertical));
}
}
}
}
let Some((s, t, from, vertical)) = best else { return (Vec2::ZERO, vec![]) };
let moved = r + u * s;
let line = if vertical {
self.line(Point::new(t, from.y.min(moved.y0)), Point::new(t, from.y.max(moved.y1)))
} else {
self.line(Point::new(from.x.min(moved.x0), t), Point::new(from.x.max(moved.x1), t))
};
let mut ov: Vec<Overlay> = line.into_iter().collect();
ov.extend(self.label(p + u * s, "align"));
(u * s, ov)
}
/// Snap a bounding-box resize. `a` is the scale [`crate::bbox::scale_for_drag`] gave for
/// dragging `handle` of the box `bx` (about the centre when `from_center`), in the box's own
/// frame, and so is the result. The handle lands on an anchor or centre within `tol` on the page
+19
View File
@@ -22,6 +22,25 @@ fn snaps_to_anchor_and_alignment() {
assert_eq!(p.y, 200.0);
}
/// #4312: a Shift move slides the selection along its line onto a ruler guide, but not far to
/// meet one a nearly parallel line only reaches a long way off.
#[test]
fn a_slide_along_a_line_lands_the_bounds_on_a_ruler_guide_within_reach() {
let t = Targets { rulers: vec![Ruler { vertical: true, pos: 300.0, span: None }], ..Targets::default() };
let r = Rect::new(248.0, 10.0, 298.0, 40.0);
let p = Point::new(270.0, 25.0);
// Level: the right edge is 2 short of the guide, which is the way along the line.
assert_eq!(t.snap_moved_along_to_rulers(p, r, Vec2::new(1.0, 0.0), 4.0).0, Vec2::new(2.0, 0.0));
// On the diagonal it is 2.8 along.
let (d, ov) = t.snap_moved_along_to_rulers(p, r, Vec2::new(1.0, 1.0), 4.0);
assert!((d - Vec2::new(2.0, 2.0)).hypot() < 1e-9 && !ov.is_empty(), "{d:?}");
// Nearly upright, it would take about 118 along the line to meet the guide.
assert_eq!(t.snap_moved_along_to_rulers(p, r, Vec2::new(0.017, 1.0), 4.0).0, Vec2::ZERO);
// A guide of an artboard needs the selection to run past it.
let bounded = Targets { rulers: vec![Ruler { vertical: true, pos: 300.0, span: Some((100.0, 200.0)) }], ..Targets::default() };
assert_eq!(bounded.snap_moved_along_to_rulers(p, r, Vec2::new(1.0, 0.0), 4.0).0, Vec2::ZERO);
}
#[test]
fn rect_snap_aligns_edges() {
let (d, id) = doc_with_rect();
+2 -2
View File
@@ -243,8 +243,8 @@ impl Tool for PenTool {
self.drag.is_some() || self.handle.is_some() || self.convert.is_some()
}
/// A path being drawn: Escape ends it.
fn ends_on_escape(&self, _cx: &ToolContext) -> bool {
self.path.is_some()
fn ends_on_escape(&self, cx: &ToolContext) -> bool {
self.active(cx).is_some()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let tol = cx.tol(5.0).max(cx.point_tol());
+8
View File
@@ -368,6 +368,14 @@ impl MoveSnap {
if q != start + d {
d = q - start;
guides = ov;
} else if guides.is_empty()
&& let Some(b) = self.bounds
{
// Nothing else lined up: the bounds slide along the line onto a ruler guide, as
// they do without Shift (#4312).
let (adj, ov) = t.snap_moved_along_to_rulers(start + d, b + d, u, cx.tol(cx.snap_tolerance));
d += adj;
guides = ov;
}
}
if cx.snap_to_pixel
+51
View File
@@ -770,6 +770,30 @@ fn a_move_snaps_the_grabbed_point_to_a_ruler_guide_with_smart_guides_on() {
assert_eq!(dx(&ToolContext { snap_to_point: false, ..cx(&d, &s, &p) }, 172.0), 22.0);
}
/// #4312: a Shift move keeps to its line while its edges and centre land on a ruler guide, as a
/// move without Shift does (#5746).
#[test]
fn a_shift_move_lands_the_edges_on_a_ruler_guide() {
let (mut d, id) = doc_with_rect();
d.guides = vec![vectorcraft_doc::Guide::new(true, 333.0)];
let mut s = Selection::default();
s.add(id);
let p = paint();
let c = cx(&d, &s, &p);
// Level; the left edge (100) would end 2 short of the guide, the pointer far from it.
let (m, _) = shift_moved(&c, (150.0 + 231.0, 150.0));
assert!(moved_by(m, (233.0, 0.0)), "{m:?}");
// The right edge, 1 past the guide.
let (m, _) = shift_moved(&c, (150.0 + 134.0, 150.0));
assert!(moved_by(m, (133.0, 0.0)), "{m:?}");
// Out of reach, or with Snap to Point off, the move stays as dragged.
let (m, _) = shift_moved(&c, (150.0 + 221.0, 150.0));
assert!(moved_by(m, (221.0, 0.0)), "{m:?}");
let off = ToolContext { snap_to_point: false, ..cx(&d, &s, &p) };
let (m, _) = shift_moved(&off, (150.0 + 231.0, 150.0));
assert!(moved_by(m, (231.0, 0.0)), "{m:?}");
}
/// Alt pressed during a move makes a copy, which snaps to the original it leaves behind (as
/// gathered before the previews moved it); released, the move doesn't.
#[test]
@@ -2426,3 +2450,30 @@ fn a_ruler_guide_under_the_centre_point_keeps_the_pointer() {
let drag = t.pointer(&c, &ev(PointerKind::Drag, 170.0, 150.0));
assert!(drag.iter().any(|a| matches!(a, Action::Preview(cmd, _) if cmd == "guide.move")), "the guide drags: {down:?} {drag:?}");
}
/// #6437: in Outline view, where an image is drawn as its frame, a click inside it on the edge of
/// a path behind selects that path, with the image selected or not. In Preview the image takes
/// the click.
#[test]
fn outline_view_click_through_an_image_selects_the_path_behind() {
use vectorcraft_doc::{Appearance, Document, ImageObject, Node, NodeKind};
let mut d = Document::new(500.0, 500.0);
let layer = d.layers[0].id;
let (back, image) = (d.alloc_id(), d.alloc_id());
let square = vectorcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0));
d.insert(Some(layer), 0, Node::path(back, square, Appearance::default_art())).unwrap();
let xf = vectorcraft_geom::Affine::translate((150.0, 150.0));
let im = ImageObject { key: "k".into(), width: 100, height: 100, xf, link: None, placement: Default::default(), pixels: None };
d.insert(Some(layer), 1, Node::new(image, NodeKind::Image(Box::new(im)))).unwrap();
let selects = |a: &[Action], id: NodeId| a.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "select.set" && v["ids"] == json!([id.0])));
let p = paint();
for picked in [vec![], vec![image]] {
let mut s = Selection::default();
s.set(picked.clone());
let outline = ToolContext { outline: true, ..cx(&d, &s, &p) };
let a = SelectionTool::default().pointer(&outline, &ev(PointerKind::Down, 200.0, 180.0));
assert!(selects(&a, back), "outline, {picked:?} selected: {a:?}");
let a = SelectionTool::default().pointer(&cx(&d, &s, &p), &ev(PointerKind::Down, 200.0, 180.0));
assert!(!selects(&a, back), "preview, {picked:?} selected: {a:?}");
}
}
+5 -1
View File
@@ -343,7 +343,11 @@ impl VectorcraftApp {
crate::script_runs::poll(self);
crate::scripts_menu::poll(self, ctx);
if self.session.script_running() && !self.session.script_waiting() && self.session.script_dialog().is_none() {
ctx.request_repaint();
// A script in `$.sleep` goes on when its time is up; until then the app can idle.
match self.session.script_sleep_left() {
Some(left) => ctx.request_repaint_after(left),
None => ctx.request_repaint(),
}
}
#[cfg(not(target_arch = "wasm32"))]
self.tick_link_updates(ctx, now);
+4 -2
View File
@@ -136,7 +136,7 @@ pub(super) fn first_tab_shown(widths: &[f32], active: Option<usize>, room: f32)
/// strip shows is the highlighted one. A tab dragged more than [`crate::doc_windows::TEAR_OFF`]
/// above or below the strip floats its view in a window under the pointer; one dragged along the
/// strip moves its document among the others (`document.move`, #5663), a line marking where it
/// will go.
/// will go. A middle-click on a tab closes it, as its × does (#6506).
pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
/// The width of the » button.
const MORE: f32 = 30.0;
@@ -200,7 +200,9 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
icons::paint(ui, "x", xr, if xresp.hovered() { t.text_strong } else { t.text });
ui.painter().galley(egui::pos2(title_left, r.center().y - galley.size().y / 2.0), galley.clone(), t.text);
hovering_art |= crate::document_drag::hover_tab(app, &resp, win, xr.expand(3.0));
if xresp.clicked() {
// A middle-click anywhere on the tab closes it as the × does (#6506), so closing a
// modified document's last view still asks to save first.
if xresp.clicked() || resp.middle_clicked() || xresp.middle_clicked() {
close = Some(win);
} else if resp.clicked() {
activate = Some(win);
+59
View File
@@ -505,6 +505,65 @@ fn a_tabs_close_box_sits_where_the_platform_puts_it() {
assert!(super::CLOSE_BEFORE_TITLE == cfg!(target_os = "macos"));
}
/// A middle-click on a document tab closes it as the × does (#6506): a saved document closes, and
/// a modified one asks to save first instead of losing its changes.
#[test]
fn a_middle_click_on_a_tab_closes_it_as_the_close_box_does() {
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
for _ in 0..3 {
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
}
// The second document has unsaved changes; the third stays the current one.
app.session.set_active(1);
app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
app.session.set_active(2);
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 200.0));
let frame = |app: &mut crate::VectorcraftApp, events: Vec<egui::Event>| {
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::doc_tabs(app, ui));
out.textures_delta.clear();
let mut titles = vec![];
for c in &out.shapes {
if let egui::Shape::Text(t) = &c.shape
&& t.visual_bounding_rect().width() > 30.0
{
titles.push(t.visual_bounding_rect());
}
}
titles.sort_by(|a, b| a.left().total_cmp(&b.left()));
titles
};
let middle_click = |app: &mut crate::VectorcraftApp, at: egui::Pos2| {
let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() };
frame(app, vec![egui::Event::PointerMoved(at), press(true)]);
frame(app, vec![press(false)])
};
let titles = frame(&mut app, vec![]);
assert_eq!(titles.len(), 3, "{titles:?}");
// The modified document's tab: the save prompt comes up and the document stays open.
let titles = middle_click(&mut app, titles[1].center());
assert_eq!(app.session.documents().len(), 3, "a modified document is not closed without asking");
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(crate::unsaved::KIND), "a modified document asks first");
app.ui.dialog = None;
// The first, saved document's tab: it closes, and the others stay open.
let uid = |app: &crate::VectorcraftApp, i: usize| app.session.documents()[i].uid;
let (second, third) = (uid(&app, 1), uid(&app, 2));
middle_click(&mut app, titles[0].center());
assert_eq!(app.session.documents().len(), 2, "the middle-click closed the first document");
assert_eq!((uid(&app, 0), uid(&app, 1)), (second, third));
assert!(app.ui.dialog.is_none(), "a saved document closes without asking");
// A middle-click on the × itself closes too: the third, saved document's.
let titles = frame(&mut app, vec![]);
assert_eq!(titles.len(), 2, "{titles:?}");
let title = titles[1];
let x = if super::CLOSE_BEFORE_TITLE { title.left() - 16.0 } else { title.right() + 20.0 };
middle_click(&mut app, egui::pos2(x, title.center().y));
assert_eq!(app.session.documents().len(), 1, "the middle-click on the × closed the third document");
assert_eq!(uid(&app, 0), second);
assert!(app.ui.dialog.is_none(), "a saved document closes without asking");
}
/// One headless frame of the status bar; returns the artboard navigator's buttons, left to
/// right: first, previous, next, last.
fn navigator(app: &mut crate::VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<egui::Rect> {
+8 -2
View File
@@ -6521,8 +6521,14 @@ breakText Glyphs Глифи
Ended at a limit Прекратен при лимит
Stopped Спрян
Ended by an internal fault Прекратен поради вътрешна грешка
{ms} ms, {calls} host calls {ms} ms, {calls} извиквания към хоста
{dropped} earlier lines dropped, {cut} lines cut {dropped} по-ранни реда премахнати, {cut} реда съкратени
time unavailable времето не е налично
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} най-бавни: {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} извикване към хоста|{n} извиквания към хоста
@plural {n} earlier line dropped|{n} earlier lines dropped {n} по-ранен ред премахнат|{n} по-ранни реда премахнати
@plural {n} line cut|{n} lines cut {n} ред съкратен|{n} реда съкратени
# Deferred plug-in effects
Refresh Deferred Effects Обновяване на отложените ефекти
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -6572,8 +6572,14 @@ shape Open Otvoren
Ended at a limit Završeno na ograničenju
Stopped Zaustavljeno
Ended by an internal fault Završeno zbog interne greške
{ms} ms, {calls} host calls {ms} ms, {calls} poziva hosta
{dropped} earlier lines dropped, {cut} lines cut {dropped} ranijih redova uklonjeno, {cut} redova skraćeno
time unavailable vrijeme nije dostupno
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} najsporije: {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} poziv hosta|{n} poziva hosta|{n} poziva hosta
@plural {n} earlier line dropped|{n} earlier lines dropped {n} raniji red uklonjen|{n} ranija reda uklonjena|{n} ranijih redova uklonjeno
@plural {n} line cut|{n} lines cut {n} red skraćen|{n} reda skraćena|{n} redova skraćeno
# Deferred plug-in effects
Refresh Deferred Effects Osvježi odgođene efekte
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -6776,8 +6776,14 @@ breakText Glyphs Glifs
Ended at a limit Acabat en un límit
Stopped Aturat
Ended by an internal fault Acabat per un error intern
{ms} ms, {calls} host calls {ms} ms, {calls} crides a l’amfitrió
{dropped} earlier lines dropped, {cut} lines cut {dropped} línies anteriors descartades, {cut} línies tallades
time unavailable temps no disponible
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} més lentes: {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} crida a l’amfitrió|{n} crides a l’amfitrió
@plural {n} earlier line dropped|{n} earlier lines dropped {n} línia anterior descartada|{n} línies anteriors descartades
@plural {n} line cut|{n} lines cut {n} línia tallada|{n} línies tallades
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` i `last` han de ser números de línies que la consola conserva, `first` abans de `last`
# Deferred plug-in effects
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -6795,8 +6795,14 @@ breakText Glyphs Glyfy
Ended at a limit Ukončeno na limitu
Stopped Zastaveno
Ended by an internal fault Ukončeno vnitřní chybou
{ms} ms, {calls} host calls {ms} ms, {calls} volání hostitele
{dropped} earlier lines dropped, {cut} lines cut {dropped} starších řádků zahozeno, {cut} řádků zkráceno
time unavailable čas není k dispozici
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} nejpomalejší: {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} volání hostitele|{n} volání hostitele|{n} volání hostitele
@plural {n} earlier line dropped|{n} earlier lines dropped {n} starší řádek zahozen|{n} starší řádky zahozeny|{n} starších řádků zahozeno
@plural {n} line cut|{n} lines cut {n} řádek zkrácen|{n} řádky zkráceny|{n} řádků zkráceno
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` a `last` musí být čísla řádků, které konzole uchovává, `first` před `last`
# Deferred plug-in effects
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -6535,8 +6535,14 @@ breakText Glyphs Glyffer
Ended at a limit Afsluttet ved en grænse
Stopped Stoppet
Ended by an internal fault Afsluttet af en intern fejl
{ms} ms, {calls} host calls {ms} ms, {calls} værtskald
{dropped} earlier lines dropped, {cut} lines cut {dropped} tidligere linjer fjernet, {cut} linjer afkortet
time unavailable tid ikke tilgængelig
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} langsomste: {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} værtskald|{n} værtskald
@plural {n} earlier line dropped|{n} earlier lines dropped {n} tidligere linje fjernet|{n} tidligere linjer fjernet
@plural {n} line cut|{n} lines cut {n} linje afkortet|{n} linjer afkortet
# Deferred plug-in effects
Refresh Deferred Effects Opdatér udskudte effekter
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -6382,8 +6382,14 @@ breakText Glyphs Glyphen
Ended at a limit An einem Limit beendet
Stopped Gestoppt
Ended by an internal fault Durch einen internen Fehler beendet
{ms} ms, {calls} host calls {ms} ms, {calls} Host-Aufrufe
{dropped} earlier lines dropped, {cut} lines cut {dropped} ältere Zeilen verworfen, {cut} Zeilen gekürzt
time unavailable Zeit nicht verfügbar
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} langsamste: {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} Host-Aufruf|{n} Host-Aufrufe
@plural {n} earlier line dropped|{n} earlier lines dropped {n} ältere Zeile verworfen|{n} ältere Zeilen verworfen
@plural {n} line cut|{n} lines cut {n} Zeile gekürzt|{n} Zeilen gekürzt
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` und `last` müssen Nummern von Zeilen sein, die die Konsole enthält, `first` vor `last`
# Deferred plug-in effects
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -6747,8 +6747,14 @@ breakText Glyphs Γλύφοι
Ended at a limit Τερματίστηκε σε όριο
Stopped Διακόπηκε
Ended by an internal fault Τερματίστηκε λόγω εσωτερικού σφάλματος
{ms} ms, {calls} host calls {ms} ms, {calls} κλήσεις προς το κεντρικό σύστημα
{dropped} earlier lines dropped, {cut} lines cut {dropped} παλαιότερες γραμμές απορρίφθηκαν, {cut} γραμμές περικόπηκαν
time unavailable ο χρόνος δεν είναι διαθέσιμος
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} πιο αργές: {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} κλήση προς το κεντρικό σύστημα|{n} κλήσεις προς το κεντρικό σύστημα
@plural {n} earlier line dropped|{n} earlier lines dropped {n} παλαιότερη γραμμή απορρίφθηκε|{n} παλαιότερες γραμμές απορρίφθηκαν
@plural {n} line cut|{n} lines cut {n} γραμμή περικόπηκε|{n} γραμμές περικόπηκαν
@msg `first` and `last` must be numbers of lines the console holds, first before last Τα `first` και `last` πρέπει να είναι αριθμοί γραμμών που κρατά η κονσόλα, το `first` πριν από το `last`
# Deferred plug-in effects
Can't render this file because it is too large.
+8 -2
View File
@@ -6738,8 +6738,14 @@ breakText Glyphs Glifos
Ended at a limit Terminado en un límite
Stopped Detenido
Ended by an internal fault Terminado por un error interno
{ms} ms, {calls} host calls {ms} ms, {calls} llamadas al host
{dropped} earlier lines dropped, {cut} lines cut {dropped} líneas anteriores descartadas, {cut} líneas recortadas
time unavailable tiempo no disponible
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} más lentos: {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} llamada al host|{n} llamadas al host
@plural {n} earlier line dropped|{n} earlier lines dropped {n} línea anterior descartada|{n} líneas anteriores descartadas
@plural {n} line cut|{n} lines cut {n} línea recortada|{n} líneas recortadas
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` y `last` deben ser números de líneas que la consola conserva, `first` antes que `last`
# Deferred plug-in effects
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -6830,8 +6830,14 @@ breakText Glyphs Merkit
Ended at a limit Päättyi rajaan
Stopped Pysäytetty
Ended by an internal fault Päättyi sisäiseen virheeseen
{ms} ms, {calls} host calls {ms} ms, {calls} isäntäkutsua
{dropped} earlier lines dropped, {cut} lines cut {dropped} aiempaa riviä poistettu, {cut} riviä katkaistu
time unavailable aika ei käytettävissä
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} hitaimmat: {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} isäntäkutsu|{n} isäntäkutsua
@plural {n} earlier line dropped|{n} earlier lines dropped {n} aiempi rivi poistettu|{n} aiempaa riviä poistettu
@plural {n} line cut|{n} lines cut {n} rivi katkaistu|{n} riviä katkaistu
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` ja `last` on oltava konsolin säilyttämien rivien numeroita, `first` ennen arvoa `last`
# Deferred plug-in effects
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -6790,8 +6790,14 @@ breakText Glyphs Glyphes
Ended at a limit Terminé à une limite
Stopped Arrêté
Ended by an internal fault Terminé par une erreur interne
{ms} ms, {calls} host calls {ms} ms, {calls} appels à l’hôte
{dropped} earlier lines dropped, {cut} lines cut {dropped} lignes antérieures supprimées, {cut} lignes coupées
time unavailable temps indisponible
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} plus lentes : {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} appel à l’hôte|{n} appels à l’hôte
@plural {n} earlier line dropped|{n} earlier lines dropped {n} ligne antérieure supprimée|{n} lignes antérieures supprimées
@plural {n} line cut|{n} lines cut {n} ligne coupée|{n} lignes coupées
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` et `last` doivent être des numéros de lignes que la console contient, `first` avant `last`
# Deferred plug-in effects
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -8236,8 +8236,14 @@ theme Appearance अपीयरेंस
Ended at a limit सीमा पर समाप्त
Stopped रोका गया
Ended by an internal fault आंतरिक त्रुटि से समाप्त
{ms} ms, {calls} host calls {ms} ms, {calls} होस्ट कॉल
{dropped} earlier lines dropped, {cut} lines cut {dropped} पुरानी पंक्तियाँ हटाई गईं, {cut} पंक्तियाँ काटी गईं
time unavailable समय उपलब्ध नहीं
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} सबसे धीमे: {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} होस्ट कॉल|{n} होस्ट कॉल
@plural {n} earlier line dropped|{n} earlier lines dropped {n} पुरानी पंक्ति हटाई गई|{n} पुरानी पंक्तियाँ हटाई गईं
@plural {n} line cut|{n} lines cut {n} पंक्ति काटी गई|{n} पंक्तियाँ काटी गईं
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` और `last` कंसोल में रखी पंक्तियों की संख्याएँ होनी चाहिए, `first` पहले और `last` बाद में
# Deferred plug-in effects
Can't render this file because it is too large.
+8 -2
View File
@@ -6779,8 +6779,14 @@ breakText Glyphs Glifák
Ended at a limit Korlátnál ért véget
Stopped Leállítva
Ended by an internal fault Belső hiba miatt ért véget
{ms} ms, {calls} host calls {ms} ms, {calls} gazdahívás
{dropped} earlier lines dropped, {cut} lines cut {dropped} korábbi sor elvetve, {cut} sor levágva
time unavailable az idő nem érhető el
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} leglassabbak: {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} gazdahívás
@plural {n} earlier line dropped|{n} earlier lines dropped {n} korábbi sor elvetve
@plural {n} line cut|{n} lines cut {n} sor levágva
@msg `first` and `last` must be numbers of lines the console holds, first before last A `first` és a `last` a konzolban tárolt sorok számai legyenek, a `first` a `last` előtt
# Deferred plug-in effects
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -6535,8 +6535,14 @@ breakText Glyphs Glif
Ended at a limit Berakhir pada batas
Stopped Dihentikan
Ended by an internal fault Berakhir karena galat internal
{ms} ms, {calls} host calls {ms} ms, {calls} panggilan host
{dropped} earlier lines dropped, {cut} lines cut {dropped} baris lama dibuang, {cut} baris dipotong
time unavailable waktu tidak tersedia
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} paling lambat: {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} panggilan host
@plural {n} earlier line dropped|{n} earlier lines dropped {n} baris lama dibuang
@plural {n} line cut|{n} lines cut {n} baris dipotong
# Deferred plug-in effects
Refresh Deferred Effects Segarkan efek yang ditunda
Can't render this file because it contains an unexpected character in line 2327 and column 5.
+8 -2
View File
@@ -6416,8 +6416,14 @@ breakText Glyphs Glifi
Ended at a limit Terminato a un limite
Stopped Interrotto
Ended by an internal fault Terminato per un errore interno
{ms} ms, {calls} host calls {ms} ms, {calls} chiamate all'host
{dropped} earlier lines dropped, {cut} lines cut {dropped} righe precedenti scartate, {cut} righe tagliate
time unavailable tempo non disponibile
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} più lenti: {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} chiamata all'host|{n} chiamate all'host
@plural {n} earlier line dropped|{n} earlier lines dropped {n} riga precedente scartata|{n} righe precedenti scartate
@plural {n} line cut|{n} lines cut {n} riga tagliata|{n} righe tagliate
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` e `last` devono essere numeri di righe presenti nella console, `first` prima di `last`
# Deferred plug-in effects
Can't render this file because it contains an unexpected character in line 2 and column 73.

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