diff --git a/Cargo.lock b/Cargo.lock index c3a087e845..74b6189119 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6687,6 +6687,7 @@ dependencies = [ "vectorcraft-geom", "wasmi", "wat", + "web-time", ] [[package]] diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 14bda9a00b..3412a7ff40 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -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, diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index cb5b19d1a4..2c0a6984d0 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -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] diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index c42b0c69de..43b339ccf4 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -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::(); - let Paint::Gradient(g) = paint else { return own }; - let freeform = g - .freeform - .as_ref() - .map_or(0, |f| f.points.len() * size_of::() + f.lines.iter().map(|l| l.len() * size_of::()).sum::()); - own + size_of::() + g.gradient.stops.len() * size_of::() + freeform + let name = |s: &Option| 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::() + + f.lines.len() * size_of::>() + + f.lines.iter().map(|l| l.len() * size_of::()).sum::() + }); + let stops: usize = g.gradient.stops.iter().map(|s| size_of::() + name(&s.swatch)).sum(); + own + size_of::() + 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 { diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index fed0a9d741..3c433d3445 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -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>> 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>> 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>> 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(_)) => {} diff --git a/crates/effects/src/stroke/dash.rs b/crates/effects/src/stroke/dash.rs index f660a6c8f7..400f3b58b9 100644 --- a/crates/effects/src/stroke/dash.rs +++ b/crates/effects/src/stroke/dash.rs @@ -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::() * 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::() > 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 diff --git a/crates/effects/src/stroke/tests_fit.rs b/crates/effects/src/stroke/tests_fit.rs index 91933087d4..b427fdbce7 100644 --- a/crates/effects/src/stroke/tests_fit.rs +++ b/crates/effects/src/stroke/tests_fit.rs @@ -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()); +} diff --git a/crates/effects/src/svg_filter.rs b/crates/effects/src/svg_filter.rs index 325ce9b38a..3877a60ae9 100644 --- a/crates/effects/src/svg_filter.rs +++ b/crates/effects/src/svg_filter.rs @@ -1101,8 +1101,9 @@ pub fn with_id(xml: &str, id: &str) -> Option { 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('\'', "'"); + // 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('\'', "'").replace('\t', " ").replace('\n', " ").replace('\r', " "); Some(format!("{}{}{}", xml.get(..start)?, id, xml.get(end..)?)) } diff --git a/crates/effects/src/trap.rs b/crates/effects/src/trap.rs index f60232bedd..b2721ed0ff 100644 --- a/crates/effects/src/trap.rs +++ b/crates/effects/src/trap.rs @@ -218,34 +218,66 @@ fn visible(sources: &[TrapSource]) -> Vec { /// 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], 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], reach_x: f64, reach_y: f64) -> Option> { + near_pairs_within(rects, reach_x, reach_y, MAX_NEAR_SCAN) +} + +fn near_pairs_within(rects: &[Option], reach_x: f64, reach_y: f64, max_scan: usize) -> Option> { 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 { + 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> { let colors: Vec> = 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 { 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 { 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> = (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> = 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> = [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)); diff --git a/crates/engine/src/cmd/addons/pathtools.rs b/crates/engine/src/cmd/addons/pathtools.rs index 5d0e5723ff..9ddcc43559 100644 --- a/crates/engine/src/cmd/addons/pathtools.rs +++ b/crates/engine/src/cmd/addons/pathtools.rs @@ -774,8 +774,9 @@ fn divide_dashes(s: &mut Session, p: &Value) -> Result { 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 { fn expand_dashes(s: &mut Session, p: &Value) -> Result { 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. diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index a5ff58ddf8..b58af91ff8 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -496,7 +496,7 @@ fn add(s: &mut Session, p: &Value) -> Result { // 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 = 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); diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index 5bea009485..534e0ec89f 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -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) { + 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) { + 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 { 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 { 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> { 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 = 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 = 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 { +/// 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> { 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::>()); - 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 = 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 diff --git a/crates/engine/src/cmd/compositefonts/tests.rs b/crates/engine/src/cmd/compositefonts/tests.rs index f51f066dfe..7997ca8f64 100644 --- a/crates/engine/src/cmd/compositefonts/tests.rs +++ b/crates/engine/src/cmd/compositefonts/tests.rs @@ -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] diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 024ba535b4..3558f1b569 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -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 = boards diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index 9bf7a311a0..a197363b23 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -270,7 +270,9 @@ fn flattened(src: &Document, at: usize, id: NodeId) -> Result> { 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); } diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 1c1989bb94..9b3223829f 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -506,7 +506,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result // 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 // 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); } diff --git a/crates/engine/src/cmd/graph/labels.rs b/crates/engine/src/cmd/graph/labels.rs index 9d521c5d9b..b115714256 100644 --- a/crates/engine/src/cmd/graph/labels.rs +++ b/crates/engine/src/cmd/graph/labels.rs @@ -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 = 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; } } diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 7f85763a7e..efcd043208 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -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(); diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 48e2d17938..a6d2cbf518 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -1303,7 +1303,7 @@ fn use_pattern(s: &mut Session, data: &Value, stroke: bool) -> Result { 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 { 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 }); diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index ed17677790..8ea016db65 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -115,7 +115,7 @@ pub fn specs() -> Vec { "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(); diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index c8453c8c01..3da73c0385 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -719,7 +719,7 @@ fn run_pf(s: &mut Session, p: &Value, op: PathfinderOp) -> Result { /// 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 { - 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 { 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())); } diff --git a/crates/engine/src/cmd/patternlib.rs b/crates/engine/src/cmd/patternlib.rs index 3e15c563fe..011dfc131c 100644 --- a/crates/engine/src/cmd/patternlib.rs +++ b/crates/engine/src/cmd/patternlib.rs @@ -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> { /// 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 { 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 diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 44de123fa9..76e09641f9 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/engine/src/cmd/printadvanced.rs b/crates/engine/src/cmd/printadvanced.rs index d181541c3d..537658447b 100644 --- a/crates/engine/src/cmd/printadvanced.rs +++ b/crates/engine/src/cmd/printadvanced.rs @@ -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; } diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index d49ea78435..493ba68db7 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Run Script", [], None, - "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file being run (empty for text) and $.includePath the #includepath list; alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict off is taken; #strict on, #targetengine and #engine are refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script), fuelLeft, fuelUsed, hostCalls and the fields above", + "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file being run (empty for text) and $.includePath the #includepath list; alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #targetengine and #engine are refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script), fuelLeft, fuelUsed, hostCalls and the fields above", always, run ), @@ -161,7 +161,9 @@ fn run(s: &mut Session, p: &Value) -> Result { _ => return Err(param("give exactly one of `path` and `source`")), }; let name = given_name.map(str::to_string).or_else(|| path.map(super::fileio::file_name)).unwrap_or_else(|| "script".to_string()); - let request = script::Request { name, name_given: given_name.is_some(), file: path.map(str::to_string), source: text, args, fuel, answers }; + let locale = script::dollar::ui_locale(s.ui_language.as_deref().unwrap_or(&s.prefs.interface_language)); + let request = + script::Request { name, name_given: given_name.is_some(), file: path.map(str::to_string), source: text, args, fuel, locale, answers }; let name = s.script_start(request)?; if s.script_frames { return Ok(json!({"running": true, "name": name})); diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index eaa5b8c545..fe2816ed16 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -753,7 +753,7 @@ fn add(s: &mut Session, p: &Value) -> Result { 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 => { diff --git a/crates/engine/src/cmd/symbollib.rs b/crates/engine/src/cmd/symbollib.rs index c757d93ea7..02767aef39 100644 --- a/crates/engine/src/cmd/symbollib.rs +++ b/crates/engine/src/cmd/symbollib.rs @@ -405,7 +405,7 @@ fn add(s: &mut Session, p: &Value) -> Result { }; // 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 = 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 { 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); diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 3fd8e8f231..89ec47b276 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -976,12 +976,12 @@ fn load(s: &mut Session, p: &Value, kinds: &[Kind]) -> Result { } 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; diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 73c938dc1a..ab0c7d342e 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -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); diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 48959c54e2..6d29550fa0 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -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 { 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)? { diff --git a/crates/engine/src/script/console.rs b/crates/engine/src/script/console.rs index 5a3b40ffe3..a0e3ee6d01 100644 --- a/crates/engine/src/script/console.rs +++ b/crates/engine/src/script/console.rs @@ -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 = slowest.iter().map(|c| format!("{} ×{} {} ms", c.id, c.calls, c.total_ms)).collect(); s.push_str("; slowest: "); diff --git a/crates/engine/src/script/dollar.rs b/crates/engine/src/script/dollar.rs new file mode 100644 index 0000000000..3e6b005844 --- /dev/null +++ b/crates/engine/src/script/dollar.rs @@ -0,0 +1,307 @@ +//! The members of a script's `$` object besides `$.write`, `$.writeln`, `$.evalFile`, `$.fileName` +//! and `$.includePath`, from the Dollar object +//! page (), and +//! the locale the global `localize` reads +//! (). +//! +//! 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 { + 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)> { + 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 { + 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) -> Data { + Data::String(s.into()) +} + +/// The members `$` has besides `write`, `writeln`, `evalFile`, `fileName` and `includePath`. +pub(super) fn members(host: &Rc, info: RunInfo, localization: &Rc) -> 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 Data>| HostItem::Getter(function(move |_| Ok(f()))); + let property = |get: Rc Data>, set: Rc 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"; diff --git a/crates/engine/src/script/include.rs b/crates/engine/src/script/include.rs index df7702d771..70bfeac56a 100644 --- a/crates/engine/src/script/include.rs +++ b/crates/engine/src/script/include.rs @@ -1,8 +1,11 @@ //! A script's preprocessor lines, read before it runs, as the public guide lists them //! (): //! `#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` (: +//! 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, + pub strict: Option, } /// 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, line: u32, column: u32, after_cr: bool, map: SourceMap, + strict: Option, } 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, dir: Option, text: &str, script: bool) -> Result { - 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}"))), diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 15d6c06e3b..213cff53fd 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -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, 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, } @@ -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, +} 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(key: &JobKey, f: impl FnOnce(&mut Job) -> T) -> Option { - 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, /// The same for each source `$.evalFile` gave, in order. eval_maps: RefCell>, - /// 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>, + /// 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, u32, u32)>>, + /// When the run started, where the platform has a clock. + started: Option, + /// The time since [`Host::started`] at which a `$.sleep` ends. + wake_at: Cell>, + /// Set while a host function runs where the run can stop at the call ([`Realm::wait_flag`]). + can_wait: Rc>, + /// What the last `#strict` line of the file `$.evalFile` last gave set (`None` without one). + eval_strict: Cell>, } -/// An error `$.evalFile` raised, as thrown, and the file, line and column it is at. -type EvalFailure = (String, Arc, 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 { + #[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 { + 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, 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) -> Vec<(String, HostItem)> { +/// being run and `$.includePath` the `#includepath` list. Its other members are +/// [`dollar::members`]. +fn dollar_object(host: &Rc, info: dollar::RunInfo, localization: &Rc) -> 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) -> 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) -> std::result::Result 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) -> std::result::Result (RunError, Option>) { (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>) { +/// `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, e: &RunError) -> (RunError, Option>) { 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 { + 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 { @@ -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> { 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 { 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 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> = const { Cell::new(None) }; /// Tests: the `JSINCLUDE` folders in place of the environment's. pub(crate) static TEST_JSINCLUDE: RefCell>> = const { RefCell::new(None) }; + /// Tests: the time since a run started, in place of its clock's. + pub(crate) static TEST_ELAPSED: Cell> = const { Cell::new(None) }; + /// Tests: runs have no clock, as in a wasm build. + pub(crate) static TEST_NO_CLOCK: Cell = const { Cell::new(false) }; } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2af42d2920..9c6de6a89e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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; diff --git a/crates/engine/src/tests/legacy_ai.rs b/crates/engine/src/tests/legacy_ai.rs index 9a9aafcbac..fefed0ad11 100644 --- a/crates/engine/src/tests/legacy_ai.rs +++ b/crates/engine/src/tests/legacy_ai.rs @@ -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 { diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index cf19f3cb1e..e1def8a6fa 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -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] diff --git a/crates/engine/src/tests/printadvanced.rs b/crates/engine/src/tests/printadvanced.rs index 99cfcd3733..8faa2f7adf 100644 --- a/crates/engine/src/tests/printadvanced.rs +++ b/crates/engine/src/tests/printadvanced.rs @@ -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] diff --git a/crates/engine/src/tests/script_console.rs b/crates/engine/src/tests/script_console.rs index ba57d96bdf..985e5b810e 100644 --- a/crates/engine/src/tests/script_console.rs +++ b/crates/engine/src/tests/script_console.rs @@ -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, 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(); diff --git a/crates/engine/src/tests/script_dollar.rs b/crates/engine/src/tests/script_dollar.rs new file mode 100644 index 0000000000..d5b9624500 --- /dev/null +++ b/crates/engine/src/tests/script_dollar.rs @@ -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"); +} diff --git a/crates/engine/src/tests/script_include.rs b/crates/engine/src/tests/script_include.rs index 945e6ca16b..0c9ab45b20 100644 --- a/crates/engine/src/tests/script_include.rs +++ b/crates/engine/src/tests/script_include.rs @@ -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); +} diff --git a/crates/engine/src/tests/svgfilter.rs b/crates/engine/src/tests/svgfilter.rs index 4edcc6fd11..92f1e49873 100644 --- a/crates/engine/src/tests/svgfilter.rs +++ b/crates/engine/src/tests/svgfilter.rs @@ -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#""#, "a")] { + let mut s = session(); + run(&mut s, "svgFilter.define", json!({"xml": held})); + let (open, close) = (format!(r#""#); + 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#"{big}"# + ); + 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#""#})); + 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#"{SHIFT_MORE}"# + ); + run(&mut s, "clipboard.importSvg", json!({"svg": svg})); + run(&mut s, "edit.pasteInPlace", json!({})); + let names: Vec = 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#""#})); + 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#"{SHIFT_MORE}"# + ); + run(&mut s, "clipboard.importSvg", json!({"svg": svg})); + run(&mut s, "edit.pasteInPlace", json!({})); + let names: Vec = 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#""#)})); + } + run(&mut s, "svgFilter.define", json!({"xml": r#""#})); + let (open, close) = (r#""#); + let big = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len())); + let svg = format!( + r#"{big}"# + ); + 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 [" ", " ", " "] { + let mut s = session(); + run(&mut s, "svgFilter.define", json!({"xml": format!(r#""#)})); + let svg = format!( + r#""# + ); + 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() { diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 4447946190..f49a88c80b 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -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), diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index de14c38b5a..947cf559d0 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -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 { + 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 = 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); diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 28f67f2152..8efa0637ad 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -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 = doc + .layers + .iter() + .map(|l| match l.kind { + NodeKind::Layer { color, .. } => color, + _ => panic!("{:?}", l.kind), + }) + .collect(); + let want: Vec = (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 { 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 { 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(); diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 0c3587e586..c598e2cd9a 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -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]) { 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); } diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs index 1e02941c31..5ebd73de3a 100644 --- a/crates/eps/src/ai_editing/writer/texts.rs +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -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) } diff --git a/crates/eps/src/ai_ops.rs b/crates/eps/src/ai_ops.rs index a22c94de53..7707f4bc45 100644 --- a/crates/eps/src/ai_ops.rs +++ b/crates/eps/src/ai_ops.rs @@ -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 } } diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 6c84eff236..c0d9852d88 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -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)]) }); diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 4f996e7841..c04d06c6ab 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -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] diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index c5c1c4c269..6b553b8d1a 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -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}; diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index c3ca85c083..979cd2a436 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -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::>()); +} + #[test] fn losses_are_warned() { let mut doc = sample(); diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index cf601e9391..c95940b95d 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -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!( diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index 62f6ade7a9..dfb2cf9f7c 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -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 { + // 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")), diff --git a/crates/js/src/builtins/extensions.rs b/crates/js/src/builtins/extensions.rs new file mode 100644 index 0000000000..15ebbe734f --- /dev/null +++ b/crates/js/src/builtins/extensions.rs @@ -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, + platform: Cell>, + enabled: Cell, +} + +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 { + 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> { + 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 { + 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 { + 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>> = 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 { + 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 { + write_source(interp, this) +} + +fn write_source(interp: &mut Interp, v: Value) -> JsResult { + 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, + 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::>())) + } + 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 { + Ok(interp.last_error.clone()) +} + +fn line(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { + 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 { + let frames: Vec<(Rc, 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(), + } +} diff --git a/crates/js/src/bytecode.rs b/crates/js/src/bytecode.rs index 0a666d1f25..9681d01681 100644 --- a/crates/js/src/bytecode.rs +++ b/crates/js/src/bytecode.rs @@ -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, + /// A function declaration's name, which `$.stack` shows. + pub(crate) decl_name: Option, pub(crate) params: Vec, pub(crate) vars: Vec, /// Function declarations to instantiate on entry: their names and indices in `functions`. diff --git a/crates/js/src/compiler.rs b/crates/js/src/compiler.rs index ae470be443..34e64814b6 100644 --- a/crates/js/src/compiler.rs +++ b/crates/js/src/compiler.rs @@ -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, + decl_name: Option, params: Vec, span: (u32, u32), } @@ -1231,6 +1233,7 @@ pub(crate) fn compile_function(ast: &Ast, fid: FunctionId, source: &Rc, 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), diff --git a/crates/js/src/extension_tests.rs b/crates/js/src/extension_tests.rs new file mode 100644 index 0000000000..19a20ab5b8 --- /dev/null +++ b/crates/js/src/extension_tests.rs @@ -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>> = 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())); +} diff --git a/crates/js/src/heap.rs b/crates/js/src/heap.rs index b2ae916e6c..0dbe949889 100644 --- a/crates/js/src/heap.rs +++ b/crates/js/src/heap.rs @@ -184,7 +184,7 @@ pub(crate) enum Func { Native { f: NativeFn, kind: NativeKind, constructor: bool, name: &'static str }, Host { f: Rc, name: Rc }, Data { f: DataFn, name: Rc, read: crate::host::ArgRead }, - Eval { f: DataFn, end: Rc, name: Rc }, + Eval { f: DataFn, end: Rc, strict_writes: Rc Option>, name: Rc }, 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)); diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 997826f3ed..4ec8f2c721 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -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, mark: u32 }, } /// A function the host gives a script, with its arguments converted. @@ -104,6 +109,12 @@ pub enum HostItem { Converted(Vec, 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, + strict_writes: Rc Option>, }, + /// 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, name: &Rc, args: &[Value]) -> JsResult { + /// 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, + strict_writes: &Rc Option>, + name: &Rc, + args: &[Value], + ) -> JsResult { 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) } diff --git a/crates/js/src/host_tests.rs b/crates/js/src/host_tests.rs index 3492cf653f..48aaa96106 100644 --- a/crates/js/src/host_tests.rs +++ b/crates/js/src/host_tests.rs @@ -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); diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 6c413e6d7e..be5760e8f9 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -164,6 +164,13 @@ pub(crate) struct Interp { pub(crate) throw_site: Option, /// 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, /// 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, + /// 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, + /// [`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, 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 = 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, 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, 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), diff --git a/crates/js/src/interp_tests.rs b/crates/js/src/interp_tests.rs index c7f69ec9b6..d2a12b5fb0 100644 --- a/crates/js/src/interp_tests.rs +++ b/crates/js/src/interp_tests.rs @@ -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) -> Code { - let source = Rc::new(Source { lines: crate::LineTable::new(&[]), text: Rc::from(Vec::::new()), tag: 0 }); + let source = Rc::new(Source { lines: crate::LineTable::new(&[]), text: Rc::from(Vec::::new()), tag: 0, strict_writes: false }); Code { positions: vec![0; ops.len()], ops, @@ -788,6 +843,7 @@ fn code(ops: Vec) -> Code { max_stack: 0, strict: false, self_name: None, + decl_name: None, params: Vec::new(), vars: Vec::new(), fun_decls: Vec::new(), diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 3936154eb7..bfb5d5c0cb 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -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; diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index 9ab7623dc4..1c2bb6ecf1 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -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 { + 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 { + 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) { diff --git a/crates/plugins/Cargo.toml b/crates/plugins/Cargo.toml index 7c18f2c066..ef21a57ef4 100644 --- a/crates/plugins/Cargo.toml +++ b/crates/plugins/Cargo.toml @@ -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 } diff --git a/crates/plugins/src/deferred.rs b/crates/plugins/src/deferred.rs index f817f32d90..1645dd55e0 100644 --- a/crates/plugins/src/deferred.rs +++ b/crates/plugins/src/deferred.rs @@ -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 Option + 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 { - #[cfg(not(target_arch = "wasm32"))] - { - static START: std::sync::OnceLock = 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 = 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] diff --git a/crates/plugins/src/export.rs b/crates/plugins/src/export.rs index b51fcfdb97..76b1316b7c 100644 --- a/crates/plugins/src/export.rs +++ b/crates/plugins/src/export.rs @@ -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. diff --git a/crates/plugins/src/host.rs b/crates/plugins/src/host.rs index 474234880b..b98407cb7c 100644 --- a/crates/plugins/src/host.rs +++ b/crates/plugins/src/host.rs @@ -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() { diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index 63f879c2f9..8b57adbe63 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -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 diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index 5abaa0bf48..863ec0a3a5 100644 --- a/crates/plugins/src/runtime.rs +++ b/crates/plugins/src/runtime.rs @@ -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 diff --git a/crates/scriptcheck/src/analyze/features.rs b/crates/scriptcheck/src/analyze/features.rs index a0fdc084ab..b5dd2299eb 100644 --- a/crates/scriptcheck/src/analyze/features.rs +++ b/crates/scriptcheck/src/analyze/features.rs @@ -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); } diff --git a/crates/scriptcheck/src/catalog.rs b/crates/scriptcheck/src/catalog.rs index 50ddee3a16..7ee71c9eea 100644 --- a/crates/scriptcheck/src/catalog.rs +++ b/crates/scriptcheck/src/catalog.rs @@ -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"; diff --git a/crates/scriptcheck/src/tests.rs b/crates/scriptcheck/src/tests.rs index 21025a6e9a..e7ead63a4f 100644 --- a/crates/scriptcheck/src/tests.rs +++ b/crates/scriptcheck/src/tests.rs @@ -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(); diff --git a/crates/testkit/src/strategies.rs b/crates/testkit/src/strategies.rs index 4dbac8f6ec..10b0608001 100644 --- a/crates/testkit/src/strategies.rs +++ b/crates/testkit/src/strategies.rs @@ -502,10 +502,26 @@ pub fn param_keys(doc: &str) -> Vec { 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 { 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 { 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)); diff --git a/crates/tools/src/addons/smart_pen.rs b/crates/tools/src/addons/smart_pen.rs index 4ee448830d..68c19cc676 100644 --- a/crates/tools/src/addons/smart_pen.rs +++ b/crates/tools/src/addons/smart_pen.rs @@ -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 { diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index 495e932a4b..e4811418c7 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -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) diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index c0ef91d240..a0fe487289 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -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] diff --git a/crates/tools/src/guides/targets.rs b/crates/tools/src/guides/targets.rs index e4039e6d9c..c50889d030 100644 --- a/crates/tools/src/guides/targets.rs +++ b/crates/tools/src/guides/targets.rs @@ -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) { + 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 = 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 diff --git a/crates/tools/src/guides/tests.rs b/crates/tools/src/guides/tests.rs index 4994104656..c34f9ab7de 100644 --- a/crates/tools/src/guides/tests.rs +++ b/crates/tools/src/guides/tests.rs @@ -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(); diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index bc564fced3..b70a160d8a 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -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 { let tol = cx.tol(5.0).max(cx.point_tol()); diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index fe666bd671..8dbdb461ea 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -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 diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 2b1151742a..67f5c91b5d 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -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:?}"); + } +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 974ef24055..7b69fbfb8d 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -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); diff --git a/crates/ui-egui/src/chrome/tabs.rs b/crates/ui-egui/src/chrome/tabs.rs index 4d5f75821b..506b149fee 100644 --- a/crates/ui-egui/src/chrome/tabs.rs +++ b/crates/ui-egui/src/chrome/tabs.rs @@ -136,7 +136,7 @@ pub(super) fn first_tab_shown(widths: &[f32], active: Option, 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); diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 15c856cd85..452fec1cc0 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -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| { + 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) -> Vec { diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 1f930dfc45..e69dc7b97c 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -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 Обновяване на отложените ефекти diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 123f2e7cf4..fc4610e0d0 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index a7c1d07c10..44562ac0f1 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3319dc953b..e42f9bd215 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index dd7af644be..cf7aa67b3f 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index f2ec5b4d36..b339033e66 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d60272a1f2..114ed5a524 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 552037a49e..c0e50d82e9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f621203399..cdbd875764 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 09b27035bb..cb81c4902c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index a2b4ff9214..296f18e805 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 46654e8bb4..34b6be0ab7 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index a48c194da0..16854b5bb7 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3dcc9cec56..6a8f3d7253 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e6cd92d6ed..aabe534b69 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6481,8 +6481,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} 回 +@plural {n} earlier line dropped|{n} earlier lines dropped 古い行 {n} 行を破棄 +@plural {n} line cut|{n} lines cut {n} 行を切り詰め @msg `first` and `last` must be numbers of lines the console holds, first before last `first` と `last` には、コンソールが保持している行の番号を、`first` が `last` より前になるよう指定してください # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 60bbfb87af..bb6a5da7d9 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6856,8 +6856,14 @@ font-script Japanese იაპონური 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} გამოძახება +@plural {n} earlier line dropped|{n} earlier lines dropped {n} ძველი სტრიქონი წაიშალა +@plural {n} line cut|{n} lines cut {n} სტრიქონი შემოკლდა @msg `first` and `last` must be numbers of lines the console holds, first before last `first` და `last` უნდა იყოს კონსოლში შენახული სტრიქონების ნომრები და `first` უნდა იყოს `last`-ზე წინ # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 4e847e0918..b7f5824635 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6377,8 +6377,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}회 +@plural {n} earlier line dropped|{n} earlier lines dropped 이전 줄 {n}개 삭제됨 +@plural {n} line cut|{n} lines cut 줄 {n}개 잘림 @msg `first` and `last` must be numbers of lines the console holds, first before last `first`와 `last`는 콘솔에 있는 줄 번호여야 하며 `first`가 `last`보다 앞서야 합니다 # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index e313809689..32db6785bb 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6586,8 +6586,14 @@ breakText Glyphs Glyphs Ended at a limit Beëindigd bij een limiet Stopped Gestopt Ended by an internal fault Beëindigd door een interne fout - {ms} ms, {calls} host calls {ms} ms, {calls} host-aanroepen - {dropped} earlier lines dropped, {cut} lines cut {dropped} eerdere regels verwijderd, {cut} regels ingekort + time unavailable tijd niet beschikbaar + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} traagste: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} host-aanroep|{n} host-aanroepen +@plural {n} earlier line dropped|{n} earlier lines dropped {n} eerdere regel verwijderd|{n} eerdere regels verwijderd +@plural {n} line cut|{n} lines cut {n} regel ingekort|{n} regels ingekort # Deferred plug-in effects Refresh Deferred Effects Uitgestelde effecten vernieuwen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index fd3e2d74b1..398adf5f50 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6651,8 +6651,14 @@ breakText Glyphs Glify Ended at a limit Zakończono na limicie Stopped Zatrzymano Ended by an internal fault Zakończono z powodu błędu wewnętrznego - {ms} ms, {calls} host calls {ms} ms, wywołania hosta: {calls} - {dropped} earlier lines dropped, {cut} lines cut Odrzucone wcześniejsze wiersze: {dropped}, skrócone wiersze: {cut} + time unavailable czas niedostępny + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} najwolniejsze: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} wywołanie hosta|{n} wywołania hosta|{n} wywołań hosta +@plural {n} earlier line dropped|{n} earlier lines dropped {n} odrzucony wcześniejszy wiersz|{n} odrzucone wcześniejsze wiersze|{n} odrzuconych wcześniejszych wierszy +@plural {n} line cut|{n} lines cut {n} skrócony wiersz|{n} skrócone wiersze|{n} skróconych wierszy @msg `first` and `last` must be numbers of lines the console holds, first before last `first` i `last` muszą być numerami wierszy przechowywanych w konsoli, `first` przed `last` # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ca72406e13..9ede5521a8 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6868,8 +6868,14 @@ swatch Yellow Green Verde-amarelado Ended at a limit Encerrado em um limite Stopped Interrompido Ended by an internal fault Encerrado por um erro interno - {ms} ms, {calls} host calls {ms} ms, {calls} chamadas ao host - {dropped} earlier lines dropped, {cut} lines cut {dropped} linhas anteriores descartadas, {cut} linhas cortadas + time unavailable tempo indisponível + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} mais lentos: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} chamada ao host|{n} chamadas ao host +@plural {n} earlier line dropped|{n} earlier lines dropped {n} linha anterior descartada|{n} linhas anteriores descartadas +@plural {n} line cut|{n} lines cut {n} linha cortada|{n} linhas cortadas @msg `first` and `last` must be numbers of lines the console holds, first before last `first` e `last` devem ser números de linhas que o console mantém, `first` antes de `last` # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 4ce19715bc..50eb9e969d 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6884,8 +6884,14 @@ shape Open Открытый Ended at a limit Завершено на лимите Stopped Остановлено Ended by an internal fault Завершено из-за внутренней ошибки - {ms} ms, {calls} host calls {ms} мс, вызовов хоста: {calls} - {dropped} earlier lines dropped, {cut} lines cut Отброшено прежних строк: {dropped}, обрезано строк: {cut} + time unavailable время недоступно + {ms} ms {ms} мс + {time}, {calls} {time}, {calls} + slowest: {commands} самые медленные: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} вызов хоста|{n} вызова хоста|{n} вызовов хоста +@plural {n} earlier line dropped|{n} earlier lines dropped {n} прежняя строка отброшена|{n} прежние строки отброшены|{n} прежних строк отброшено +@plural {n} line cut|{n} lines cut {n} строка обрезана|{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 diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 07c4fc48bf..60324bb91e 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8280,8 +8280,14 @@ digits Hindi Hindi Ended at a limit Avslutades vid en gräns Stopped Stoppad Ended by an internal fault Avslutades av ett internt fel - {ms} ms, {calls} host calls {ms} ms, {calls} värdanrop - {dropped} earlier lines dropped, {cut} lines cut {dropped} tidigare rader borttagna, {cut} rader avkortade + time unavailable tid ej tillgänglig + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} långsammast: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} värdanrop|{n} värdanrop +@plural {n} earlier line dropped|{n} earlier lines dropped {n} tidigare rad borttagen|{n} tidigare rader borttagna +@plural {n} line cut|{n} lines cut {n} rad avkortad|{n} rader avkortade @msg `first` and `last` must be numbers of lines the console holds, first before last `first` och `last` måste vara nummer på rader som konsolen har kvar, `first` före `last` # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index fd842dd342..79cbd7a648 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -227,6 +227,24 @@ fn each_language_counts_with_its_plural_rule() { } } +/// The Script Console's counted phrases have a row in every complete catalog, and a count picks +/// the language's own form. +#[test] +fn script_console_counts_take_each_language_s_form() { + const COUNTED: [(&str, &str); 3] = + [("{n} host call", "{n} host calls"), ("{n} earlier line dropped", "{n} earlier lines dropped"), ("{n} line cut", "{n} lines cut")]; + for l in LANGUAGES.iter().filter(|l| l.complete_menus) { + for (one, other) in COUNTED { + assert!(l.catalog().plural(one, other, 0).is_some(), "{} is missing the row for {other}", l.code); + } + } + assert_eq!([1, 2].map(|n| trn(de(), n, COUNTED[0].0, COUNTED[0].1)), ["1 Host-Aufruf", "2 Host-Aufrufe"]); + let ru = Lang::from_code("ru").expect("registered"); + assert_eq!([1, 3, 5].map(|n| trn(ru, n, COUNTED[2].0, COUNTED[2].1)), ["1 строка обрезана", "3 строки обрезаны", "5 строк обрезано"]); + let fr = Lang::from_code("fr").expect("registered"); + assert_eq!([0, 1].map(|n| trn(fr, n, COUNTED[1].0, COUNTED[1].1)), ["0 ligne antérieure supprimée", "1 ligne antérieure supprimée"]); +} + #[test] fn candidates_walk_from_specific_to_general() { assert_eq!(candidates("pt_BR.UTF-8"), ["pt-br", "pt"]); diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 21cdd3733e..496dba8cf5 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6751,8 +6751,14 @@ breakText Glyphs Glifler Ended at a limit Bir sınırda sona erdi Stopped Durduruldu Ended by an internal fault Bir iç hata nedeniyle sona erdi - {ms} ms, {calls} host calls {ms} ms, {calls} ana bilgisayar çağrısı - {dropped} earlier lines dropped, {cut} lines cut {dropped} eski satır atıldı, {cut} satır kesildi + time unavailable süre kullanılamıyor + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} en yavaşlar: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} ana bilgisayar çağrısı +@plural {n} earlier line dropped|{n} earlier lines dropped {n} eski satır atıldı +@plural {n} line cut|{n} lines cut {n} satır kesildi @msg `first` and `last` must be numbers of lines the console holds, first before last `first` ve `last`, konsolun tuttuğu satırların numaraları olmalı ve `first`, `last` değerinden önce gelmeli # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 255c72c05b..c1b1c3bdaf 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6396,8 +6396,14 @@ breakText Glyphs Гліфи Ended at a limit Завершено на ліміті Stopped Зупинено Ended by an internal fault Завершено через внутрішню помилку - {ms} ms, {calls} host calls {ms} мс, викликів хоста: {calls} - {dropped} earlier lines dropped, {cut} lines cut Відкинуто попередніх рядків: {dropped}, обрізано рядків: {cut} + time unavailable час недоступний + {ms} ms {ms} мс + {time}, {calls} {time}, {calls} + slowest: {commands} найповільніші: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} виклик хоста|{n} виклики хоста|{n} викликів хоста +@plural {n} earlier line dropped|{n} earlier lines dropped {n} попередній рядок відкинуто|{n} попередні рядки відкинуто|{n} попередніх рядків відкинуто +@plural {n} line cut|{n} lines cut {n} рядок обрізано|{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 diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6cd9716236..d458c348ee 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6397,8 +6397,14 @@ breakText Glyphs Glyph Ended at a limit Kết thúc ở giới hạn Stopped Đã dừng Ended by an internal fault Kết thúc do lỗi nội bộ - {ms} ms, {calls} host calls {ms} ms, {calls} lệnh gọi máy chủ - {dropped} earlier lines dropped, {cut} lines cut Đã bỏ {dropped} dòng cũ, đã cắt {cut} dòng + time unavailable không có thời gian + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} chậm nhất: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} lệnh gọi máy chủ +@plural {n} earlier line dropped|{n} earlier lines dropped đã bỏ {n} dòng cũ +@plural {n} line cut|{n} lines cut đã cắt {n} dòng @msg `first` and `last` must be numbers of lines the console holds, first before last `first` và `last` phải là số thứ tự của các dòng mà bảng điều khiển đang giữ, `first` đứng trước `last` # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index bbd5ab2333..028bd60571 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6633,8 +6633,14 @@ breakText Glyphs 字形 Ended at a limit 在限制处结束 Stopped 已停止 Ended by an internal fault 因内部错误结束 - {ms} ms, {calls} host calls {ms} 毫秒,宿主调用 {calls} 次 - {dropped} earlier lines dropped, {cut} lines cut 已丢弃 {dropped} 行较早内容,已截断 {cut} 行 + time unavailable 时间不可用 + {ms} ms {ms} 毫秒 + {time}, {calls} {time},{calls} + slowest: {commands} 最慢的命令:{commands} + {dropped}, {cut} {dropped},{cut} +@plural {n} host call|{n} host calls 宿主调用 {n} 次 +@plural {n} earlier line dropped|{n} earlier lines dropped 已丢弃 {n} 行较早内容 +@plural {n} line cut|{n} lines cut 已截断 {n} 行 @msg `first` and `last` must be numbers of lines the console holds, first before last `first` 和 `last` 必须是控制台保留的行号,且 `first` 不能在 `last` 之后 # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index dbc2cf2485..7cb218f99f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6431,8 +6431,14 @@ breakText Glyphs 字符 Ended at a limit 在限制處結束 Stopped 已停止 Ended by an internal fault 因內部錯誤結束 - {ms} ms, {calls} host calls {ms} 毫秒,主機呼叫 {calls} 次 - {dropped} earlier lines dropped, {cut} lines cut 已捨棄 {dropped} 行較早內容,已截斷 {cut} 行 + time unavailable 時間無法取得 + {ms} ms {ms} 毫秒 + {time}, {calls} {time},{calls} + slowest: {commands} 最慢的命令:{commands} + {dropped}, {cut} {dropped},{cut} +@plural {n} host call|{n} host calls 主機呼叫 {n} 次 +@plural {n} earlier line dropped|{n} earlier lines dropped 已捨棄 {n} 行較早內容 +@plural {n} line cut|{n} lines cut 已截斷 {n} 行 @msg `first` and `last` must be numbers of lines the console holds, first before last `first` 和 `last` 必須是主控台保留的行號,且 `first` 不能在 `last` 之後 # Deferred plug-in effects diff --git a/crates/ui-egui/src/menus/entries.rs b/crates/ui-egui/src/menus/entries.rs index 3c308970d6..815a00d6f9 100644 --- a/crates/ui-egui/src/menus/entries.rs +++ b/crates/ui-egui/src/menus/entries.rs @@ -1,6 +1,7 @@ use super::context::context_items; use super::invoke::click_target; use super::labels::{display_label, dynamic_label, effect_item_label, item_shortcut, spelling_item_label, unlock_item_label}; +use super::slots::{recent_readable, recent_slot}; use super::state::{shortcut_of, shown_state}; use super::tree::menu_tree_named; use super::{Entry, Item, Serialize, Value, VectorcraftApp}; @@ -133,6 +134,10 @@ fn flatten(app: &VectorcraftApp, path: Vec, items: &[Item], out: &mut Ve if *id == crate::scripts_menu::COMMAND && !crate::scripts_menu::readable(p) { continue; } + // The other rows keep their ids: `file.openRecent3` still opens the third file. + if id.starts_with("file.openRecent") && recent_slot(app, id).is_some_and(|path| !recent_readable(path)) { + continue; + } let Some((enabled, _)) = shown_state(app, id, p) else { continue }; out.push(MenuEntry { path: path.clone(), diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 536ad79eb1..1223e1e426 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -1,6 +1,6 @@ use super::bar::home_key; use super::invoke::menu_dialog; -use super::slots::{recent_slot, saved_selection_name, saved_selection_slot, saved_view, saved_view_slot}; +use super::slots::{recent_readable, recent_slot, saved_selection_name, saved_selection_slot, saved_view, saved_view_slot}; use super::state::{checked, selected_image}; use super::ui_commands::UI_COMMANDS; use super::{Brightness, Face, SaveMode, Value, VectorcraftApp, io, json, next_zoom}; @@ -137,7 +137,7 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Ok(io::hide_exports(app)), "file.cancelOpen" => Ok(crate::opening::cancel_all(app)), "file.openInBackground" => Ok(crate::opening::hide_all(app)), - id if id.starts_with("file.openRecent") => match recent_slot(app, id).cloned() { + id if id.starts_with("file.openRecent") => match recent_slot(app, id).filter(|path| recent_readable(path)).cloned() { Some(path) => io::open_reporting(app, &path), None => Err("no such recent file".into()), }, @@ -164,7 +164,7 @@ fn run_ui_part2(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Ok(json!(io::recent_files(app))), + "file.recentFiles" => Ok(json!(io::recent_files(app).iter().filter(|path| recent_readable(path)).collect::>())), "file.place" => crate::place::run(app, p), "file.place.queue" => crate::place::queue(app, p), "file.export.svg" if p.as_object().is_none_or(|o| o.is_empty()) => { diff --git a/crates/ui-egui/src/menus/slots.rs b/crates/ui-egui/src/menus/slots.rs index 2dee19dcff..5bd3bfb006 100644 --- a/crates/ui-egui/src/menus/slots.rs +++ b/crates/ui-egui/src/menus/slots.rs @@ -51,6 +51,14 @@ pub(super) fn recent_slot<'a>(app: &'a VectorcraftApp, id: &str) -> Option<&'a S io::recent_files(app).get(n.checked_sub(1)?) } +/// May the request being served read recent file `path`? Under an automation read root (a control +/// client's request), a recent file outside the root is left out of `ui.menu.list` and +/// `file.recentFiles`, and its `file.openRecentN` slot answers as an empty one, as a listed script +/// outside the root is left out ([`crate::scripts_menu::readable`]). +pub(super) fn recent_readable(path: &str) -> bool { + vectorcraft_engine::file_access::check_read(path).is_ok() +} + /// View → Perspective Grid → One/Two/Three Point Perspective: the saved-preset slots of each type. pub(super) const PERSPECTIVE_SLOTS: [[&str; crate::dialogs::perspective_presets::SLOTS]; 3] = [ [ diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 06dcf85bcb..a96f13e67c 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -137,7 +137,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{folder?} Type › Find Font's Find in Folder…: asks for a folder (or takes folder), then opens Missing Fonts looking there for the files of the fonts the active document misses (text.findFontFiles). Disabled where there is no folder picker (the web)", ), - ("file.recentFiles", "Recent Files", "", "{} → [path…] most recent first"), + ("file.recentFiles", "Recent Files", "", "{} → [path…] most recent first; under an automation read root, only the files inside it"), ( "file.export.svg", "Export As SVG…", diff --git a/crates/ui-egui/src/panels/script_console.rs b/crates/ui-egui/src/panels/script_console.rs index 3a3a325000..78540b2115 100644 --- a/crates/ui-egui/src/panels/script_console.rs +++ b/crates/ui-egui/src/panels/script_console.rs @@ -39,10 +39,13 @@ pub(crate) fn shown(line: &ConsoleLine) -> String { Entry::Output(t) | Entry::Log(t) | Entry::Error(t) => t.clone(), Entry::End(e) => format!("■ {}", ending_label(*e)), Entry::Report { ms, host_calls, slowest } => { - let ms = ms.map_or_else(|| "?".to_string(), |ms| ms.to_string()); - let mut s = crate::i18n::fmt(tl!("{ms} ms, {calls} host calls"), &[("ms", &ms), ("calls", &host_calls.to_string())]); - for c in slowest { - s.push_str(&format!("; {} ×{} {} ms", c.id, c.calls, c.total_ms)); + let time = ms.map_or_else(|| tl!("time unavailable").to_string(), millis); + let calls = crate::i18n::tn(*host_calls, "{n} host call", "{n} host calls"); + let mut s = crate::i18n::fmt(tl!("{time}, {calls}"), &[("time", &time), ("calls", &calls)]); + if !slowest.is_empty() { + let list: Vec = slowest.iter().map(|c| format!("{} ×{} {}", c.id, c.calls, millis(c.total_ms))).collect(); + s.push_str("; "); + s.push_str(&crate::i18n::fmt(tl!("slowest: {commands}"), &[("commands", &list.join(", "))])); } s } @@ -50,13 +53,17 @@ pub(crate) fn shown(line: &ConsoleLine) -> String { format!("#{} {}: {}", line.run, line.name, text) } +/// A time in milliseconds, in the UI language. +fn millis(ms: u64) -> String { + crate::i18n::fmt(tl!("{ms} ms"), &[("ms", &ms.to_string())]) +} + /// The notice of the lines the console dropped and cut, or `None` when it has done neither. fn notice(c: &Console) -> Option { (c.dropped_lines() > 0 || c.truncated_lines() > 0).then(|| { - crate::i18n::fmt( - tl!("{dropped} earlier lines dropped, {cut} lines cut"), - &[("dropped", &c.dropped_lines().to_string()), ("cut", &c.truncated_lines().to_string())], - ) + let dropped = crate::i18n::tn(c.dropped_lines(), "{n} earlier line dropped", "{n} earlier lines dropped"); + let cut = crate::i18n::tn(c.truncated_lines(), "{n} line cut", "{n} lines cut"); + crate::i18n::fmt(tl!("{dropped}, {cut}"), &[("dropped", &dropped), ("cut", &cut)]) }) } diff --git a/crates/ui-egui/src/panels/tests_script_console.rs b/crates/ui-egui/src/panels/tests_script_console.rs index 414e1bb36c..c1fbcc4da9 100644 --- a/crates/ui-egui/src/panels/tests_script_console.rs +++ b/crates/ui-egui/src/panels/tests_script_console.rs @@ -6,7 +6,7 @@ use serde_json::json; use vectorcraft_engine::Session; use super::pstate; -use super::script_console::{DRAWN, ID, show}; +use super::script_console::{DRAWN, ID, show, shown}; use crate::tests_labels::painted_text; use crate::{Services, VectorcraftApp, script_runs}; @@ -96,3 +96,48 @@ fn copy_all_and_copy_selected_put_the_lines_on_the_clipboard() { let all = app.clipboard_out.take().unwrap(); assert!(all.lines().next().is_some_and(|l| l.contains("lines dropped")), "Copy All starts with the notice: {}", &all[..80]); } + +/// A report line reads in English as `script.console` gives it: "time unavailable" for a run +/// without a clock, one host call in the singular, and the slowest commands. +#[test] +fn a_report_line_reads_as_script_console_gives_it() { + use vectorcraft_engine::script::console::{ConsoleLine, Entry, Slow}; + let slow = vec![Slow { id: "a.b".into(), calls: 2, total_ms: 7, max_ms: 4 }, Slow { id: "c".into(), calls: 1, total_ms: 1, max_ms: 1 }]; + for ms in [None, Some(0), Some(12)] { + for host_calls in [0, 1, 2] { + for slowest in [vec![], slow.clone()] { + let entry = Entry::Report { ms, host_calls, slowest }; + let want = format!("#3 c.js: {}", entry.text()); + assert_eq!(shown(&ConsoleLine { run: 3, name: "c.js".into(), entry }), want); + } + } + } + let line = |ms, host_calls| shown(&ConsoleLine { run: 1, name: "c.js".into(), entry: Entry::Report { ms, host_calls, slowest: vec![] } }); + assert_eq!(line(None, 1), "#1 c.js: time unavailable, 1 host call"); + assert_eq!(line(Some(0), 2), "#1 c.js: 0 ms, 2 host calls"); +} + +/// The notice counts the lines dropped and the lines cut each in its own form, a count of one in +/// the singular; Copy All starts with it. +#[test] +fn the_notice_counts_one_line_in_the_singular() { + for (dropped, cut, want) in [ + (1, 0, "1 earlier line dropped, 0 lines cut"), + (0, 1, "0 earlier lines dropped, 1 line cut"), + (1, 2, "1 earlier line dropped, 2 lines cut"), + (2, 1, "2 earlier lines dropped, 1 line cut"), + (2, 2, "2 earlier lines dropped, 2 lines cut"), + ] { + let mut app = app(); + // A run adds a start, an end and a report line to its output lines. + let lines = if dropped > 0 { vectorcraft_engine::script::console::MAX_CONSOLE_LINES - 3 + dropped } else { cut + 1 }; + let source = format!("var big = 'x'; while (big.length < 5000) big += big; for (var i = 0; i < {lines}; i++) $.writeln(i < {cut} ? big : i)"); + run(&mut app, &source); + let c = app.session.script_console(); + assert_eq!((c.dropped_lines(), c.truncated_lines()), (dropped as u64, cut as u64)); + app.run("ui.copyScriptConsole", json!({})).unwrap(); + let all = app.clipboard_out.take().unwrap(); + assert_eq!(all.lines().next(), Some(want)); + assert!(painted_text(&mut app, show).contains(want)); + } +} diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index c66cc7cd5a..50c57db650 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -304,6 +304,62 @@ fn frames_with_injected_input_are_confined() { let _ = std::fs::remove_dir_all(base); } +/// Every File › Open Recent Files row `ui.menu.list` returns, disabled ones included: (id, label), +/// the label of a disabled row starting "(disabled) ". +fn recent_rows(entries: &Value) -> Vec<(String, String)> { + let rows = entries.as_array().map(Vec::as_slice).unwrap_or_default(); + rows.iter() + .filter(|e| e["command"].as_str().is_some_and(|c| c.starts_with("file.openRecent"))) + .map(|e| { + let label = e["label"].as_str().unwrap_or_default(); + let label = if e["enabled"] == true { label.to_string() } else { format!("(disabled) {label}") }; + (e["command"].as_str().unwrap_or_default().to_string(), label) + }) + .collect() +} + +#[test] +fn recent_files_outside_the_read_root_are_not_listed_or_opened() { + let (mut app, tx, base) = confined("recent"); + std::fs::copy(base.join("in/a.svg"), base.join("in/c.svg")).unwrap(); + let (a, b, c) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg")), s(base.join("in/c.svg"))); + app.ui.recent_files = vec![a.clone(), b.clone(), c.clone()]; + let rows = |pairs: &[(&str, &str)]| pairs.iter().map(|(id, l)| (id.to_string(), l.to_string())).collect::>(); + + // The person's menu lists all three. + let person = serde_json::to_value(crate::menus::menu_entries(&app)).unwrap(); + assert_eq!(recent_rows(&person), rows(&[("file.openRecent1", "a.svg"), ("file.openRecent2", "b.svg"), ("file.openRecent3", "c.svg")])); + assert_eq!(app.run("file.recentFiles", json!({})).unwrap(), json!([a, b, c])); + + // The client's listing leaves out the second, and the third keeps its id. + let r = request(&mut app, &tx, "ui.menu.list", json!({})); + assert_eq!(recent_rows(&r["result"]), rows(&[("file.openRecent1", "a.svg"), ("file.openRecent3", "c.svg")]), "{r}"); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "file.recentFiles"})); + assert_eq!(r["result"], json!([a, c]), "{r}"); + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "file.openRecent2"})); + assert_eq!(r["error"], "no such recent file", "the reply names no path: {r}"); + assert!(app.session.documents().is_empty()); + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "file.openRecent3"})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(app.session.documents().len(), 1); + assert_eq!(app.ui.recent_files, [c.clone(), a.clone(), b.clone()]); + + // A file outside the root that the person has open is still not brought forward. + app.run("file.openRecent3", json!({})).unwrap(); + assert_eq!((app.session.documents().len(), &app.ui.recent_files[0]), (2, &b)); + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "file.openRecent1"})); + assert_eq!(r["error"], "no such recent file", "{r}"); + + // Rows past Number of Recent Files to Display stay out, and a write root alone lists none. + app.session.prefs.recent_files_count = 2; + let r = request(&mut app, &tx, "ui.menu.list", json!({})); + assert_eq!(recent_rows(&r["result"]), rows(&[("file.openRecent2", "c.svg")]), "{r}"); + let write_only = AutomationRoots::new(&[], &[&base.join("out")]).unwrap(); + let listed = vectorcraft_engine::file_access::confine(write_only.as_ref(), || serde_json::to_value(crate::menus::menu_entries(&app)).unwrap()); + assert!(recent_rows(&listed).is_empty(), "{listed}"); + let _ = std::fs::remove_dir_all(base); +} + /// Hidden logic-only ticks between the injection and its UI pass must not drain another step: /// one step per settled pass, not per hidden tick. #[test] diff --git a/crates/ui-egui/src/tests/rotateview.rs b/crates/ui-egui/src/tests/rotateview.rs index 2f98a3f3b1..dbdb0283fd 100644 --- a/crates/ui-egui/src/tests/rotateview.rs +++ b/crates/ui-egui/src/tests/rotateview.rs @@ -535,6 +535,23 @@ fn escape_that_ends_a_path_being_drawn_keeps_the_rotation() { } } +/// A path that is no longer selected is not being drawn: the next Escape resets the view. +#[test] +fn escape_after_deselecting_the_path_resets_the_view() { + for tool in ["pen", "smartPen", "curvature"] { + let mut w = Window::new(); + w.app.select_tool(tool); + let c = w.app.canvas_rect.unwrap().center(); + w.click(0.0, egui::PointerButton::Primary, c); + w.click(0.1, egui::PointerButton::Primary, c + egui::vec2(60.0, 10.0)); + assert!(w.app.session.tool_ends_on_escape(w.app.view_info()), "{tool}: a path is being drawn"); + w.app.run("select.none", json!({})).unwrap(); + assert!(!w.app.session.tool_ends_on_escape(w.app.view_info()), "{tool}: the path is not selected"); + w.escape(0.2); + assert_eq!(w.rotation(), 0.0, "{tool}: one Escape resets the view"); + } +} + /// In isolation mode, an Escape that ends the Pen's path ends only the path: the next Escape leaves /// isolation mode, and the one after it resets the view. #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 02cc5c4384..96e5baffb3 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -691,7 +691,8 @@ In the desktop app a Paste from the menus or the keys (`ui.key`) decodes a bitma `edit.paste*` reads it at once. File menu details: `file.openRecent1`…`file.openRecent30` open the recent files the `recentFilesCount` preference -lists (0 hides them all; `file.recentFiles` returns the listed paths), `file.reveal` shows the document's file in the +lists (0 hides them all; `file.recentFiles` returns the listed paths; under a read root, `ui.menu.list` and +`file.recentFiles` leave out the files outside it and `file.openRecentN` answers `no such recent file` for one), `file.reveal` shows the document's file in the system file manager (desktop only; answers `{path}`), and `file.newFromTemplate` without params opens a file picker in the Templates folder (preference `templatesFolder`). Older native files open as " [Converted]" (preference `appendConverted`), so their Save asks for a new `.vectorcraft` name. Native files reopen at the view they were saved diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 56d25f12a2..df33fa8b63 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -340,6 +340,8 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| | 2026-11-11 | minor | `.ai` and EPS import: while an envelope's contents are edited, the editing data keeps its mesh (a mesh's, a top object's or a warp's grid) in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art, as if the mesh hadn't followed the envelope when it moved: the envelope drew its contents elsewhere and kept its drawn art. A mesh or top object saved in Edit Contents (or a warp of type tried on its saved grid) whose mesh draws the contents as large as the drawn art, within half a point over width and height together, but further from it than that, is now tried again, after every other way and with one more of the import's checks, with its mesh moved by as far as the drawn art's box lies from the box of what it draws; it opens live there where it then draws as the drawn art. In users' files, used locally and never committed, only envelopes saved in Edit Contents draw elsewhere than their drawn art, and nothing in their dictionaries says by how much they moved. Tests `ai::tests::an_envelope_in_edit_contents_whose_mesh_stayed_behind_opens_live_on_its_drawn_art`, `tests_native::a_warp_of_type_in_edit_contents_whose_grid_stayed_behind_opens_on_its_drawn_art` (#6570) | +| 2026-11-11 | minor | `.ai` save (PDF-compatible editing copy and legacy) and `.ai`/EPS open: a layer's `Lb` colour index follows the file's numbering, where Magenta, Cyan and Yellow are 4, 5 and 6 and `LAYER_COLORS` has Yellow, Magenta and Cyan; Yellow, Magenta and Cyan layers no longer come out as Magenta, Cyan and Yellow, and the other 24 presets are unchanged (#6502, from the index and RGB pairs in users' files). `LAYER_COLORS` keeps its order, so `.vectorcraft` files and the Layers panel don't change. Scores and estimates unchanged. | +| 2026-11-11 | minor | `.ai` save: stroked type writes its stroke's cap, join and miter limit into the text document (keys 61, 62 and 64, as `.ai` import reads them since #6500), so they come back when the file opens again and no longer count among the attributes of type left out; a run without a stroke doesn't count them either, as they draw nothing. Tests `ai_editing::tests::type_comes_back_as_type`, `an_unstroked_run_with_a_stroke_style_loses_nothing` (follow-up to #6472) | | 2026-11-11 | minor | `.ai` and EPS import, PDF import, SVG export and `.ai` save: type with a Horizontal Scale other than 100% and tracking or manual kerning is spaced as a `.ai`'s own page spaces it. Layout now measures tracking and manual kerning in thousandths of the em as the scale draws it (see type-parity.md, same date); they were added at the full em, so such type drifted from the page letter by letter. A `.ai`'s page writes that type's character spacing as the tracking itself in a text space as wide as the scale, which PDF scales by it (ISO 32000-1 §9.4.4). In 224 users' files (used locally, never committed), 93 render differently. Of the 25 whose render moved by more than 1% of its pixels against the page, 23 came closer. The note on the type differing from the page shows a smaller share in 5 files (11.0% to 4.4% at most) and a larger one in 2 (by 0.2 to 0.3 points). 9 more envelopes of type open live (113 to 122 of 203, none fewer in any file), and no file switched between its layers and its PDF part. PDF import: the tracking of horizontally scaled type is the file's `Tc` in thousandths of its size (it was that times the scale), the glyphs where they were (test `tests::text_placement::character_spacing_in_horizontally_scaled_type_is_the_files_tc`). SVG export writes the letter spacing in thousandths of the font size it writes (the vertical scale), so type scaled uniformly keeps its spacing in a viewer (test `text_layout::spacing_in_uniformly_scaled_type_takes_the_scale`); tracked type scaled horizontally alone still differs a little there, as before. A `.ai` saved here writes the same values as before; VectorCraft now lays them out as the file's own PDF part draws them. Scores and estimates unchanged. | | 2026-11-11 | minor | `.ai` and EPS import: stroked type in an envelope with Distort Appearance is tried mapped from its glyphs grown by as far as its strokes paint along a side (half the weight of a centred stroke), without what miter joins add at corners, for a stroke on its appearance or on a group round it as well as its characters'; it was mapped from its bounds with strokes, which reach as far as a miter join may at a corner. Tests `ai::envelope::tests::stroked_type_is_tried_mapped_from_its_glyphs_grown_by_half_its_stroke`, `type_with_a_character_stroke_is_tried_mapped_from_its_bounds_with_strokes`, `a_stroke_on_a_group_of_type_grows_the_box_by_half_its_weight` (part of #3179) | | 2026-11-11 | minor | `.ai` and EPS import: a warp of type (an envelope made with a warp around type) whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the warp grid the file saves, with the same drawn-art check, budget and page comparison as #6421: where it passes, the type is editable and keeps the file's shape, and the warp's settings (style, bend) are lost. A second try is only made while more checks are left than there are envelopes of type and warped text objects still to come. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its box with strokes, where the size the file saves for it doesn't already give that box (a rule chosen by measuring real files). An envelope's grid (warp, mesh or top object) now reads its way round from all its rows and columns, not its first row alone: a mesh bent far round (its end columns leaning more than halfway over) reads the way it runs, not turned a quarter round, and a grid symmetric about its middle reads the same wherever it is on the page. Tests `tests_native::a_warp_of_type_opens_as_a_mesh_on_its_saved_grid_and_keeps_its_shape_when_its_text_changes`, `a_warp_of_stroked_type_maps_from_its_bounds_with_strokes_where_its_drawn_art_does`, `a_warp_of_type_without_a_saved_grid_keeps_its_drawn_art`, `a_warp_of_type_tried_again_leaves_the_next_envelope_its_check`, `ai::tests::a_mesh_envelope_bent_far_round_opens_the_way_it_runs`, `a_mesh_envelope_bent_halfway_round_opens_the_same_way_wherever_it_is` (part of #3179) | diff --git a/docs/gaps.md b/docs/gaps.md index dd0c3491ed..19376c4bf2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -687,7 +687,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi ## G6 Automation and scripting -- **Missing:** the document object model for scripts and script directives (#1183 is +- **Missing:** the document object model for scripts and the `#targetengine` and `#engine` script directives (#1183 is open). File › Scripts › Other Script… and `script.run` run JavaScript files over the command registry, File › Scripts lists the scripts of the Scripts folder, and Window › Script Console shows their output, errors and run reports. Actions, Batch, Variables (text, visibility, image and graph), data set CSV import and diff --git a/docs/mcp.md b/docs/mcp.md index 013574e591..7d66c7c0b3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -112,6 +112,9 @@ the suite) keep the agent to the folders you give it: export commands, Export for Screens, Package, Export for Web, print to file, `screenshot {path}`, library and preset saves to a path, the copies `links.relink {copy}` makes. - `file.list {path}` (through `run_command`) lists a folder's files and folders, held to the read roots like any read, so an agent can see what it was given (#1163). +- `file.recentFiles` and the control channel's `ui.menu.list` leave out the recent files outside the read roots. The + other File › Open Recent Files rows keep their ids (`file.openRecent3` still opens the third file), and + `file.openRecentN` for a file outside the roots ends with `no such recent file`, as an empty slot does. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an output folder and a scratch folder, without widening a root to their common parent. A folder given twice @@ -1595,15 +1598,74 @@ VectorCraft reads them by these rules of its own: folder of the file that includes. A later `#includepath` replaces the list. - `#script "name"` names the run in the report and the Script Console, unless `name` is given. A `#script` line in an included file does not rename the run. -- `#target` takes any value and does not change how the script runs. `#strict off` is taken. -- `#strict on`, `#targetengine` and `#engine` end the run with an unsupported-directive error, and any other `#name` - with an unknown-directive error. A `//@` line with another name is a comment. +- `#target` takes any value and does not change how the script runs. +- `#strict on` turns on the checks the dollar-object help page gives for `$.strict`: an assignment to a read-only + property throws a `TypeError`, and so does adding a property to an object that takes no new ones. By VectorCraft's + own rules, an assignment to a property with only a getter is checked as a read-only one, and the objects that take + no new properties are those that are not extensible: objects passed to `Object.preventExtensions`, `Object.seal` or + `Object.freeze`, and the frozen `vc` and `$` objects. Other objects take new properties as before. The rest of ES5 + strict mode (`with`, undeclared variables, `this` in a plain call) is unchanged; a script asks for it with + `'use strict'`. `#strict off` turns the checks off, and they are off in a script without either line. +- The last `#strict` line of the script and the files it includes sets the checks for the whole run. Code that `eval` + and `Function` compile takes the checks of the code that compiles it. A file `$.evalFile` runs takes the checks of + its own last `#strict` line, for its code and the functions it defines, or the caller's when it has none; the + caller's code keeps its own. Any value other than `on` or `off` ends the run with `#strict takes on or off`. +- `#targetengine` and `#engine` end the run with an unsupported-directive error, and any other `#name` with an + unknown-directive error. A `//@` line with another name is a comment. - A script run from a file reads its included files and the files `$.evalFile` runs only in that file's folder and the folders below it. A path with a `..` name, a path that leads out of the folder through a link or a junction, a Windows device name or stream, and a computer on the network are refused, and so is every path for a script given as `source`. A `JSINCLUDE` folder outside the script's folder is passed over. - A missing or refused included file ends the run before any of it runs. An error in an included file or a file `$.evalFile` - ran is reported at that file's line and column, and the report's `file` names the file. + ran is reported at that file's line and column, and the report's `file` names the file. So is an error `$.evalFile` + throws for a file it cannot run, such as a syntax error, when no `catch` takes it. When a `catch` takes that error + and a later `throw` raises it again, the error is reported where that `throw` is, as any other thrown error is. + +`$` also has the other members the Dollar object page lists, besides `$.evalFile`, `$.fileName` and `$.includePath` +above. Each run +starts with its own `$`: what a script changes there lasts until the run ends. The values and rules below are +VectorCraft's own, and `vc.api.has("dollar")` is true where they are available: + +| Member | In VectorCraft | +|--------|----------------| +| `about()` | `"VectorCraft script engine "`; no window opens | +| `appEncoding` | `"UTF-8"`; setting `"UTF-8"` is accepted, any other encoding throws a `RangeError` | +| `bp()`, `colorPicker()` | throw an `Error`: scripts have no debugger and cannot show a dialog | +| `build`, `version` | VectorCraft's version | +| `buildDate` | `null` | +| `decimalPoint` | `","` for a locale whose language writes a decimal comma (such as `de`, `fr`, `ru`), otherwise `"."` | +| `engineName` | `"VectorCraft"` | +| `error` | the last `Error` object the run threw, caught or not, or `null` before one | +| `flags` | `0`, or `512` while localization is on; only `0` and `512` can be set, and setting `512` turns `$.localize` on | +| `gc()` | does nothing; the interpreter collects its own garbage | +| `getenv(name)`, `setenv(name, value)` | only `LANG`, `LC_MESSAGES`, `LC_ALL` and `OS`; `getenv` of any other name returns `null`, and `setenv` of one throws. `setenv` changes the value for the rest of the run only, never the app's environment. A value has at most 64 letters, digits and `_ . - @` | +| `global` | the global object | +| `hiresTimer` | the microseconds since the run last read it, or since the run started; `0` on the web | +| `level`, `memCache` | `0`; setting `memCache` throws | +| `line`, `stack` | the line of the code that reads it; the running code, outermost first, one a line as `name:line` (`[global]` for the script's own code, `[anonymous]` for a function without a name), at most 64 lines | +| `locale` | the interface's language as a locale such as `de` or `pt_BR` (`en` when the interface follows the system); a script can set `ll` or `ll_RR`, and `null` or `""` sets the interface's again | +| `localize` | `false`; while `true`, `String(object)` of a localization object gives its text for `$.locale` | +| `os` | `Windows`, `macOS`, `Linux`, `FreeBSD`, `Unix` or `Web`, without a version | +| `screens` | an empty array | +| `sleep(ms)` | waits from 0 to 1,000 milliseconds between two frames of the app, without stopping it from drawing; `script.stop` ends the wait; it does not wait on the web; called with more than 0 inside a getter, a conversion, a callback of a built-in function or a file `$.evalFile` runs, it throws an `Error` instead of waiting | +| `strict` | `false` | + +Members that are values (`build`, `level`, `os` and the like) cannot be changed. A wrong value for a member that can +be set throws a `TypeError` or `RangeError`. + +`localize(object, ...values)` gives the text of a localization object, an object whose property names are locales +(https://extendscript.docsforadobe.dev/extendscript-tools-features/localizing-extendscript-strings/). It looks up the +locale with the platform's suffix (`_Win`, `_Mac` or `_Unix`; none on the web), the locale, its language with the +suffix, its language, and then `en`, and returns the object itself when none of them is a string property of it. No +getter runs. `%1` is replaced by the first value, `%2` by the second and so on, each once; a number with no value stays +as it is. A string as the first argument is used as the text. + +`toSource(value)` and `value.toSource()` write `undefined`, `null`, booleans, numbers, strings, arrays and plain +objects as the text of an expression that makes the same value, such as `({"a":[1, "x"]})`. A function, a getter, a +date, a regular expression, an error, a host object such as `vc`, an array with properties besides its elements, an +object whose prototype is not `Object.prototype`, a property that is not enumerable, or an object that contains +itself throws a `TypeError`. A value deeper than 64 levels, with more than 1,048,576 entries or with text longer than 16,777,216 +characters throws a `RangeError`. The run is one transaction. When the script ends, each document it changed gets one undo step named Run Script. A run that changes no document adds no undo step. The journal records each command the run finished, as it records @@ -1728,7 +1790,12 @@ at the bottom of the panel and in its menu. Output goes on while the panel is cl - `script.console {}` returns `{lines: [{run, name, kind, text}], droppedLines, truncatedLines}`. `run` is the run's number in the session, and `kind` is `start`, `output`, `log`, `error`, `end` or `report`. An end line's text - is `ended:`, a space, and then `finished`, `error`, `fuel`, `hostLimit`, `limit`, `stopped` or `internal`. + is `ended:`, a space, and then `finished`, `error`, `fuel`, `hostLimit`, `limit`, `stopped` or `internal`. A report + line's text is the run's time (`12 ms`, or `time unavailable` on a platform without a clock), a comma and its host + calls (`1 host call`, `2 host calls`), then `; slowest:` and the slowest commands when any were timed. The panel + shows the same text in the interface language. Every run that starts gets an end line. When a run is no longer + available, its end line is `stopped` if the run was abandoned and `internal` if the next slice found it missing, and + no report line follows. - `script.console.clear {}` empties the console, sets its counts to zero and returns `{cleared}`. A running script goes on writing, and `scriptReport` keeps the last run's console. - `ui.copyScriptConsole {first?, last?}` copies lines `first` to `last` to the clipboard as the panel shows them, or @@ -3743,7 +3810,8 @@ what every object that uses it draws, in the edit's one undo step, and an applic filter. Deleting a filter that objects use leaves their effects in place, still named, drawing nothing (their `xml` empty) until a filter of that name is defined or imported again. A pasted effect that names one of the document's filters but draws another definition keeps its own filter under a free name (`Soft 2`); pasted back into the document -it was copied from, it takes the document's filter. Opened from such an effect, Apply SVG Filter shows the +it was copied from, it takes the document's filter. The free name is one that no filter of the document and no effect in +its art, brushes or graphic styles uses; when no such name keeps the filter within 64 KB, the paste is refused. Opened from such an effect, Apply SVG Filter shows the name above the list, not selectable. `effect.apply {effect: "svg.filter", params: {xml}}` takes a filter directly and names it by its `id`; an `xml` that isn't one such filter is an error. `ui.svgFilterDialog {}` opens Apply SVG Filter (New, Edit, Delete, Import…), and from the Appearance panel it edits which filter an applied effect runs; @@ -5232,7 +5300,8 @@ fills or strokes carry effects is written as one piece per fill and stroke, so o When an image in the art can't be decoded, `object.rasterize` and `object.cropImage` (without `trim`) make their image without it, and their results carry the `warning` that Flatten Transparency gives. `object.rasterize` takes each frame of threaded text that it rasterizes out of its thread with the text it shows, as `type.createOutlines` does (see -Threading text from the in and out ports), and the other frames keep the text they hold. +Threading text from the in and out ports), and the other frames keep the text they hold. `object.rasterize` +refuses an image larger than 64 million pixels or longer than 32,768 pixels a side, and the art stays as it was. ```json {"name":"run_command","arguments":{"command":"document.rasterEffectsSettings","params":{"resolution":"high","background":"white","addAround":36}}} @@ -6185,7 +6254,9 @@ as Overprint Preview shows them (darkened); either way the file has no overprint overprints. `flattenerPreset` (High Resolution, Medium Resolution, Low Resolution or a saved preset, `flattener.presets.list`) flattens transparency before printing, as Object → Flatten Transparency does; empty, a PDF keeps it live (PostScript, which has none, uses `file.print`'s `flattenerPreset`, else this one, else medium). -`printAsBitmap` prints each composite page as one image of the art at the document's raster effects resolution. +`printAsBitmap` prints each composite page as one image of the art at the document's raster effects resolution, +or at the highest resolution that fits when the image would be larger than 64 million pixels or longer than 32,768 +pixels a side. `color.profile` names an RGB or CMYK profile: composite PDF colours are converted to it with `color.intent` (CMYK colours keep their numbers with `preserveNumbers`), so a CMYK profile writes device CMYK; separations separate with a CMYK one. Settings saved before these options load with their defaults. @@ -7319,8 +7390,8 @@ and the stages with a part of their input too small for the drawing the refresh timed out, failed or have no plug-in. `id` names one object in place of `ids`, and an entry that is not an object id of the document is an error. Each document has its own budget and its own deferred stages. Exports, Expand Appearance and commands run every stage under the per-call limit, so an export never leaves out a stage that was only -deferred for time, and the canvas then draws the result. The web build has no clock for the budget: there the first -view runs no plug-in effect, and Refresh Deferred Effects runs them. An exporter adds a write-only file format `plugin.` (listed by `document.formats`): +deferred for time, and the canvas then draws the result. The web build measures the budget and the per-call +limit with the page's `performance.now()`. An exporter adds a write-only file format `plugin.` (listed by `document.formats`): `document.export {format: "plugin.", params?, path?}` writes it, as does a `path` ending in one of its extensions that no built-in format has. An importer adds a read-only file format `plugin.` (listed by `document.formats` with `read: true`, and `openExtensions` holds the extensions File › Open opens with it, without those a built-in format reads or that File › Open installs, imports or loads as something else, such as `.wasm` or `.ase`): `document.open {path | name, dataBase64, importer?: diff --git a/docs/plugins.md b/docs/plugins.md index 55de6cdafc..06fd89f28a 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -128,7 +128,7 @@ The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssem | Linear memory | 512 MiB per instance (input and parameters: at most half) | `memory.grow` returns -1; oversized input or initial memory fails | | Output | 64 MiB of JSON | `Limit("output size")` | | Instructions (fuel) | 50 M per call + 4,000 per input byte for `vc_run`; a [heavy filter](#heavy-filters) 1 G + 4,000 per byte; an export or import 10 G + 4,000 per byte | `Limit("instruction budget")` | -| Wall clock | 60 s per filter run, 5 s per live-effect run, 300 s per heavy filter run, export or import (desktop; checked every 20 M instructions) | `Limit("time budget")` | +| Wall clock | 60 s per filter run, 5 s per live-effect run, 300 s per heavy filter run, export or import (checked every 20 M instructions; the web build reads the page's `performance.now()`) | `Limit("time budget")` | | Import input | 128 MiB of the file (`vectorcraft_plugins::import::MAX_INPUT_BYTES`) | the file doesn't open; a larger file named by `path` is not read past that | | Call depth | 1,024 frames | trap | | Module shape | wasmi's strict limits (≤ 10,000 functions, 1 memory, …) | rejected | @@ -147,8 +147,8 @@ refuses or has no plug-in, draws its input art unchanged and is deferred. The st are deferred, and later drawings leave them as they are until their input or parameters change. Effect › Refresh Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget, and `effect.deferredStatus` lists them with the reason (`budget`, `timeout`, `missing`, `failed`, `safeMode`). Each document has its own budget. -Exports, Expand Appearance and commands run every stage under the 5 s limit. The web build has no clock for the -budget, so there the first view runs no live effect and Refresh Deferred Effects runs them. +Exports, Expand Appearance and commands run every stage under the 5 s limit. The web build has the same budget, +measured with the page's `performance.now()`. ### Heavy filters @@ -170,7 +170,7 @@ much there is only once the run has ended. Cancel (also Esc) and Continue in Background (`plugin.cancelRuns`, `plugin.hideRuns`). When it is done its result is applied as `plugin.run` with the same parameters and objects, as one undo step, unless the document or the plug-in changed meanwhile: then nothing changes, and the status bar says to run it again. On the web, which has no threads, - it runs at once; the instruction budget alone bounds it there. + it runs at once, under the same instruction budget and 300 s limit. - `plugin.run` itself runs a heavy filter where it is called, as it runs any filter: an MCP client waits for it, and its `notifications/cancelled` stops it. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6a64e869a1..357e0f7f0e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -732,8 +732,13 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The control server writes each reply through a 64 KiB buffer. On Windows 11 it sends the 17 MB `document.inspect` reply of a document with 50,000 objects on one layer to a client over loopback in 0.09 s instead of 34 s. - Scripts: File › Scripts › Other Script… and `script.run` run a JavaScript file against the open documents as one transaction with one undo step for each changed document. A failed or stopped run puts every document back. `crates/engine/src/tests/script_run.rs`. - Scripts: Window › Script Console shows each run's `$.write` and `$.writeln` output, `vc.log` entries, uncaught error with the script's name, line and column, and a report of its time, host calls and slowest commands, keeping at most 2,048 lines and counting the lines it dropped. `script.run`'s report carries the same data, and `script.console`, `script.console.clear` and `ui.copyScriptConsole` read, clear and copy it. `crates/engine/src/tests/script_console.rs`. - - Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, and `#strict on`, `#targetengine` and `#engine` are refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`). + - Scripts: the `$` object has the other members the Dollar object page lists (https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/), from `$.about()` to `$.version`, and scripts have the global `localize(object, ...values)` (https://extendscript.docsforadobe.dev/extendscript-tools-features/localizing-extendscript-strings/), `toSource(value)` and `Object.prototype.toSource()`. Each run starts with the interface's locale, and what a script changes in `$` lasts only for that run. `crates/engine/src/tests/script_dollar.rs`, `crates/js/src/extension_tests.rs`. + - Scripts: the Script Console panel's report line reads as `script.console` gives it, `time unavailable` included on a platform without a clock; counts of host calls, dropped lines and cut lines take the singular for one, in each language's own plural forms; and a run that is no longer available still gets its end line (`crates/engine/src/tests/script_console.rs`, `crates/ui-egui/src/panels/tests_script_console.rs`). + - Scripts: an error `$.evalFile` throws for a file it cannot run is reported at that file's line and column only while no `catch` has taken it. Caught and thrown again later, in the script or in another file, it is reported where it was thrown again, and a new error whose message holds its text is reported where that error was thrown. VectorCraft's own rule. `crates/engine/src/tests/script_include.rs`, `crates/js/src/host_tests.rs`. + - Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, `#strict on` turns on strict write checks (below), and `#targetengine` and `#engine` are refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`). + - Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`). - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens, links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. + - Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`. - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. - MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172). @@ -1650,7 +1655,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Filter plug-ins call commands (G17, #2186):** a filter at plug-in API level 2 with the `commands` permission names the commands it may run in its manifest's `commandAllowlist` and runs them with `env.vc_call`, reading each result with `env.vc_call_result`. Each call sees what the calls before it changed; the calls and the filter's output are one undo step, and a run that fails, is canceled or makes a call that fails leaves every document, its selection and its history as they were. Commands that read or write files, use the clipboard, change preferences or the view, run scripts, actions or plug-ins, switch documents or undo are never called, and a manifest that lists one is refused when the module is installed. The install dialog lists the allowed commands. A run makes at most 1,000 calls, and each call costs 10,000 instructions of its budget. Regressions: `crates/plugins/tests/call.rs`, `crates/engine/tests/plugin_call.rs`, `crates/engine/src/cmd/plugin_call.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. -- **Live-effect plug-ins while a document opens (G17):** the live-effect plug-in runs of a document's first view on screen share a 2,000 ms budget (VectorCraft's own value). A stage left out for time, or one that times out, fails, has no plug-in or meets Safe Mode, draws its input unchanged and is deferred; the status bar says how many, `effect.deferredStatus` lists them with their object, effect and reason, and Effect › Refresh Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget. Each document has its own budget, an edit runs the stages it changes, and exports, Expand Appearance and commands run every stage under the 5 s limit of each run. Regressions: `crates/plugins/src/deferred.rs`, `crates/plugins/src/effect.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/canvas/tests.rs`. Scores and hours unchanged. +- **Live-effect plug-ins while a document opens (G17):** the live-effect plug-in runs of a document's first view on screen share a 2,000 ms budget (VectorCraft's own value). A stage left out for time, or one that times out, fails, has no plug-in or meets Safe Mode, draws its input unchanged and is deferred; the status bar says how many, `effect.deferredStatus` lists them with their object, effect and reason, and Effect › Refresh Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget. Each document has its own budget, an edit runs the stages it changes, and exports, Expand Appearance and commands run every stage under the 5 s limit of each run. The web build times the budget and each run's limit with the page's `performance.now()`. Regressions: `crates/plugins/src/deferred.rs`, `crates/plugins/src/effect.rs`, `crates/plugins/src/host.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/canvas/tests.rs`. Scores and hours unchanged. - **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_clear_cache.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in settings and cache totals in memory (#6228):** the settings stay in memory while `settings.json` keeps its size and modified time, a set to the same value writes nothing, every other change is written to the file before the call returns, and `cacheBytes` comes from a total kept while the `cache` folder keeps its modified time. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. @@ -2123,6 +2128,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-20 | minor | Type core: Hyphenation… and Justification… no longer listed as missing (done in #2310, #2755); evidence: a paragraph's Auto Leading drives the Character panel's Leading, the leading nudge and Fill with Placeholder Text; scores unchanged | | 2026-10-22 | minor | Drawing tools: a Pen, Anchor Point or Direct Selection drag's preview no longer stalls (#834: the position where the pointer re-entered the window could be skipped, and the move in the release's frame was lost); score and hours unchanged (estimated) | | 2026-10-23 | minor | Guides and snapping: a path moved by a press on its stroke or beside its edge (Selection, Group Selection, Direct Selection), and a straight segment Direct Selection drags, snap by the nearest point of the path, so the path lands exactly on a ruler guide with "align" instead of the pressed spot landing there and the path a few pixels beside it (#3701); `tests_prefs::a_move_held_by_a_paths_stroke_lands_the_path_on_a_ruler_guide`, `tests_prefs::a_straight_segment_dragged_by_direct_selection_lands_on_a_ruler_guide`. Score and hours unchanged. | +| 2026-11-11 | minor | Guides and snapping: a Shift move slides the selection along its line onto a ruler guide by its edges and centre, as a move without Shift does (#4312); a guide a nearly parallel line only reaches a long way off pulls nothing. Score and hours unchanged (estimated). | | 2026-10-22 | minor | Live vector effects: the Transform effect has Transform Objects, Transform Patterns and Scale Strokes & Effects, its checkboxes visible (#885); the area row now lists only the options on a fill's or stroke's own Transform effect. Score and hours unchanged (estimated). | | 2026-10-23 | minor | COLR colour fonts (v0 and v1) draw and export as vector art; evidence added; scores unchanged. | | 2026-10-22 | minor | Libraries panel: the buttons beside the library picker show the items as icons (the default) or in a list, colours and graphics as rows with their names (a colour's hex value too), dragged and dropped by row; graphic thumbnails are shrunk to the size they are drawn at, so thin strokes stay whole. Tests: `panels::libraries::tests::the_list_view_shows_names_and_takes_drops_by_row`, `the_panel_draws_its_libraries`. Scores and hours unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index cee195adaa..44d0b19a4e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -158,7 +158,7 @@ tools, never checked side by side) isn't captured row by row. | Anchor Point and Curvature tools' own cursors | done | the caret on the anchor it converts; the pen nib with a curve badge | | Free Transform's Cmd/Ctrl side-shear cursor | done | two arrows passing along the side (ShearH, ShearV), instead of the resize arrow across it | | Cursors of their own for Lasso, Magic Wand, the freehand tools, Shaper, Live Paint Bucket, Measure, Print Tiling, the symbolism tools, vertical type, Scissors (over a segment and away), Mesh and Puppet Warp | partial | each tool returns its own cursor and Use Precise Cursors turns them into the crosshair; until they're drawn they borrow the crosshair, the I-beam, the Pen's add cursor or not-allowed | -| Blob Brush and Eraser: the tip's outline at the pointer | done | before a press and while painting, the outline of the tip (its size, angle and roundness, at the zoom) follows the pointer, black on a white halo, round a small centre dot; `[` / `]` and Tool Options change it at once; precise cursors (Caps Lock) show only the crosshair; it goes when the pointer leaves the canvas, while Cmd/Ctrl lends a selection tool and while Alt lends the Blob Brush the Smooth tool (#4977); `crates/tools/src/draw2/gesture.rs` and its tests | +| Blob Brush and Eraser: the tip's outline at the pointer | done | before a press and while painting, the outline of the tip (its size, angle and roundness, at the zoom) follows the pointer, black on a white halo, round a small centre dot; `[` / `]` and Tool Options change it at once; precise cursors (Caps Lock) show only the crosshair; it goes when the pointer leaves the canvas, while Cmd/Ctrl lends a selection tool and while Alt lends the Blob Brush the Smooth tool (#4977); `crates/tools/src/draw2/gesture.rs` and its tests While the button is down a round Blob Brush shows the outline of the area it will add, rounded at both ends, not a line as wide as the brush that ends square (#4343). | | Busy cursor during long operations | done | Background open, save and export show the system progress cursor (gap G1) | | Each anchor snaps to Smart Guides and construction angles | done | `DrawSnap` | | Curvature tool: click/drag points, double-click or Alt-click for a corner, temporary Direct Selection, point conversion | partial | circular point markers without direction handles or duplicate markers, Anchor Size and Hide Edges regression-tested (#1236, in part); curve geometry: each stretch of three points follows the circle through them (handles along its tangent, chord/(3·cos²(α/2)) long, shorter in a turn tighter than 30°), open ends curving in, the same rule for the rubber band, dragged, inserted and converted points (#1236, in part); neighbours of a point dragged on an existing path keep their tangents, and modifiers requested in #975 remain | @@ -365,6 +365,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po |---|---|---| | Selection artwork drag hovers a visible document tab to activate it, then drops editable artwork on its canvas | done | `tests_document_drag`, `edit.dropSelection`, lossless resource and one-step Undo regressions; overflow and cross-window drag remain separate | | A document tab dragged along the tab strip moves its document; a line marks where it goes, the current document and every document's view stay as they were, and a drag off the strip still floats it | done | #5663, [public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html); `document.move`; `tests::tab_reorder`, `chrome::tests` (drop index with floating and view tabs), `engine` `tests::document_move` | +| A middle-click on a document tab or its × closes it as the × does; closing a modified document's last view asks to save first (VectorCraft addition, as in PhotoCraft) | done | #6506; `chrome::tests::a_middle_click_on_a_tab_closes_it_as_the_close_box_does`; not scored | | Behaviour | Status | Notes | |---|---|---| @@ -440,6 +441,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Tabs panel: a stop drags from where the press landed, a stop just added included | done | #1458 | | Outline view keeps template layers in Preview, dimmed | done | #1486; a picture placed to be traced stays visible under the outlines | | Outline view draws on a white canvas, the artboards as their edges | done | Outline shows only the paths ([Outline mode](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/preview-artwork-as-outlines.html)). VectorCraft's own rule for the canvas: in every interface brightness and Canvas Color the whole canvas is white, pasteboard included; the artboards draw no paper (no Simulate Colored Paper or artboard color, and no paper with Hide Artboards); the artboard names and the ruler origin's crosshair are dark gray; split views and floating windows follow their own view. Tests `outline_draws_on_a_white_canvas_with_artboards_as_edges`, `outline_draws_no_colored_paper`, `a_pane_draws_its_own_views_outline`, `the_ruler_origin_crosshair_shows_on_the_outline_canvas` | +| Outline view: a click selects by the outlines drawn, so it reaches a path behind an image (picked on its frame, unless Show Images in Outline Mode draws its pixels) or a gradient mesh (picked on its mesh lines) | done | #6437; in Outline mode the path is selected directly ([Object selection methods and preferences](https://helpx.adobe.com/illustrator/desktop/manage-objects/select-objects/object-selection-methods-and-preferences.html), Object Selection by Path Only). Type, symbol instances, blends, envelopes and placed documents are still picked within their bounds. Tests `outline_mode_picks_paths_through_images_and_meshes`, `outline_view_click_through_an_image_selects_the_path_behind` | | Arrange Tools add-on (VectorCraft addition): Object › Plug-ins › Arrange Tools runs Arrange in Grid… (grid by row, one row or one column; columns, H and V spacing, Center on Artboard; Preview), Swap Objects, Swap Objects and Sizes, Replace with Top Object… (Actual Size, Bounds or Bounds (Fit Proportionally); Keep Original; Scale Strokes & Effects; Preview), Set Size of Each… (Width and Height each ticked on or off, Constrain Width and Height Proportions with one side, the reference point, Scale Strokes & Effects; Preview) and Zoom to Selection, each one undo step | done | `addon.arrange.*`, `crates/engine/src/cmd/addons/arrange.rs`, `crates/ui-egui/src/addons/arrange.rs` and their tests; not a feature of the reference app, so it is not scored | | Arrange Tools add-on (VectorCraft addition): Object › Plug-ins › Arrange Tools › Group Overlapping Objects groups the selected objects whose visual bounds overlap or touch (chains included, within one layer or group), each group at its frontmost member's stacking place, one undo step | done | #4928; `addon.arrange.groupOverlapping`, `crates/engine/src/cmd/addons/arrange.rs` and its tests; not a feature of the reference app, so it is not scored | | Arrange Tools add-on (VectorCraft addition): Object › Plug-ins › Arrange Tools › Randomize Opacity… gives each selected object its own opacity at random between Minimum and Maximum (%), with a Seed that repeats it and Preview, one undo step | done | #4925; `addon.arrange.randomizeOpacity`, `crates/engine/src/cmd/addons/arrange.rs`, `crates/ui-egui/src/addons/arrange.rs` and their tests; not a feature of the reference app, so it is not scored | @@ -509,6 +511,8 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Outline view: a click on a path's outline selects it through an image or a gradient mesh in front, which Outline draws as a frame or mesh lines (#6437). | +| 2026-11-11 | minor | A middle-click on a document tab (or its ×) closes it as the × does; closing a document's last view with unsaved changes asks to save first (#6506). VectorCraft addition; scores and estimates unchanged. | | 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. | | 2026-11-10 | minor | Symbol libraries: Inventory Icons joins Basic Shapes, UI Frames and Map Markers as a fourth built-in library (Sword, Shield, Potion, Key, Gem; no default 9-slice). | | 2026-11-11 | minor | Files that record no units (PDF, EPS or `.ai` without ruler units, raster images, metafiles, Affinity files, importer plug-ins) open and revert in Preferences › Units › General instead of points; native, SVG and DXF files and `.ai`/EPS ruler units are kept (#5820). Scores and estimates unchanged. | @@ -549,6 +553,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-11-01 | minor | Arrange Tools add-on (a VectorCraft addition): Randomize Opacity (#4925); scores and estimates unchanged | | 2026-11-01 | minor | Arrange Tools add-on (a VectorCraft addition): Sort Objects (#4917); scores and estimates unchanged | | 2026-11-01 | minor | Blob Brush and Eraser: the tip's outline (size, angle, roundness, at the zoom) follows the pointer before and while painting, around a small centre dot, updating at once with `[` / `]` and Tool Options; precise cursors (Caps Lock) show only the crosshair (#4977); scores and estimates unchanged (estimated) | +| 2026-11-11 | minor | Blob Brush: while painting, the preview of a round tip is the outline of the area the stroke adds, round at both ends, as the stroke it commits (#4343). Scores unchanged (estimated). | | 2026-11-01 | minor | Layers panel: an open blend lists its spine as a `` row below its key objects, and a click selects the blend so Direct Selection edits the spine (#4820); scores and estimates unchanged | | 2026-11-01 | minor | Pen with Cmd/Ctrl held: the lent Selection tool no longer scales the path being drawn by bounding-box handles the canvas hides while its end anchor is selected (a flat path did nothing under a resize pointer); a Cmd drag moves the path instead, and with Direct Selection moves anchors and segments, one undo step each (#4980); scores and estimates unchanged | | 2026-11-01 | minor | Dynamic symbols (#4938): Symbol Options › Symbol Type; per-instance appearance for objects of a dynamic symbol's art; Reset in the Properties panel and the Symbols panel menu; on-canvas Direct Selection of the parts remains |