Merge main into feat/script-file-folder

This commit is contained in:
dotCooCoo
2026-11-11 07:47:42 -08:00
94 changed files with 2522 additions and 204 deletions
+7 -3
View File
@@ -870,10 +870,10 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio
on_type_path(n, t, p, opt.tol).then_some(HitKind::Outline)
}
// An image is hit within its frame (on the frame's edge only with Selection by Path Only,
// #3291), and where its own fills and strokes paint ([`image_paint`]) except in outline
// mode.
// #3291, and in outline mode where the frame is all that is drawn, #6437), and where its
// own fills and strokes paint ([`image_paint`]) except in outline mode.
NodeKind::Image(im) => {
if opt.path_only {
if opt.path_only || (opt.outline && !symbols.document().setup.outline_images) {
use vectorcraft_geom::kurbo::Shape;
let mut frame = Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)).to_path(0.1);
frame.apply_affine(im.xf);
@@ -909,6 +909,10 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio
if stroke_contains(&bp, 0.0, opt.tol, p) {
return Some(HitKind::Outline);
}
// Outline mode draws the mesh lines, not the colours between them (#6437).
if opt.outline {
return stroke_contains(&m.lines().to_bezpath(), 0.0, opt.tol, p).then_some(HitKind::Outline);
}
(!opt.path_only && fill_contains(&bp, vectorcraft_geom::FillRule::NonZero, p)).then_some(HitKind::Fill)
}
_ => None,
+39
View File
@@ -880,6 +880,45 @@ fn path_only_picks_paths_through_images() {
assert_eq!(hit(image_edge, path_only), Some(image), "path only: the image's frame");
}
/// #6437: in outline mode an image is drawn as its frame and a gradient mesh as its mesh lines,
/// so a click inside them, on the outline of a path behind, picks that path, and a click on the
/// frame or a mesh line picks the image or the mesh. In Preview they cover the path as before,
/// and an image whose pixels show in outline mode (Show Images in Outline Mode) still does.
#[test]
fn outline_mode_picks_paths_through_images_and_meshes() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let (path, image, under, mesh) = (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id());
d.insert(Some(l), 0, Node::path(path, shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::default_art())).unwrap();
let xf = vectorcraft_geom::Affine::translate((10.0, 10.0));
let im = crate::ImageObject { key: "k".into(), width: 80, height: 80, xf, link: None, placement: Default::default(), pixels: None };
d.insert(Some(l), 1, Node::new(image, NodeKind::Image(Box::new(im)))).unwrap();
d.insert(Some(l), 2, Node::path(under, shapes::rectangle(Rect::new(120.0, 120.0, 160.0, 160.0)), Appearance::default_art())).unwrap();
let area = shapes::rectangle(Rect::new(110.0, 110.0, 190.0, 190.0));
let m = crate::GradientMesh::for_path_with(&area, 2, 2, &|_| (Color::BLACK, 1.0)).unwrap();
d.insert(Some(l), 3, Node::new(mesh, NodeKind::Mesh(m))).unwrap();
let outline = HitOptions { outline: true, ..Default::default() };
let hit = |d: &Document, p: Point, opt| hit_test(d, p, opt).map(|h| h.leaf);
// The image: the path's edge and the inside, then the frame.
let (path_edge, inside, frame) = (Point::new(20.5, 40.0), Point::new(80.0, 80.0), Point::new(89.5, 50.0));
assert_eq!(hit(&d, path_edge, HitOptions::default()), Some(image), "Preview: the image covers the path");
assert_eq!(hit(&d, inside, HitOptions::default()), Some(image));
assert_eq!(hit(&d, path_edge, outline), Some(path), "outline: the path through the image");
assert_eq!(hit(&d, inside, outline), None, "outline: not inside the image");
assert_eq!(hit(&d, frame, outline), Some(image), "outline: the image's frame");
// The mesh: the path's edge and the inside, then its edge and a mesh line inside it.
let (under_edge, inside, edge, line) = (Point::new(120.5, 130.0), Point::new(130.0, 170.0), Point::new(189.5, 130.0), Point::new(150.0, 170.0));
assert_eq!(hit(&d, under_edge, HitOptions::default()), Some(mesh), "Preview: the mesh covers the path");
assert_eq!(hit(&d, inside, HitOptions::default()), Some(mesh));
assert_eq!(hit(&d, under_edge, outline), Some(under), "outline: the path through the mesh");
assert_eq!(hit(&d, inside, outline), None, "outline: not between the mesh lines");
assert_eq!(hit(&d, edge, outline), Some(mesh), "outline: the mesh's edge");
assert_eq!(hit(&d, line, outline), Some(mesh), "outline: a mesh line");
// Show Images in Outline Mode: the image's pixels are drawn, and it covers the path again.
d.setup.outline_images = true;
assert_eq!(hit(&d, path_edge, outline), Some(image));
}
/// Type Object Selection by Path Only: point type is picked on its baseline, area type on its
/// frame and type on a path on its path, no longer anywhere in its bounds.
#[test]
+23 -7
View File
@@ -25,17 +25,33 @@ const MAX_OVERLAP_TESTS: usize = 1 << 22;
/// The width of the stroke around each face that covers the seams between faces.
const SEAM_WIDTH: f64 = 0.75;
/// About the bytes one copy of `paint` takes.
/// About the bytes one copy of `paint` takes, with the strings it owns (swatch and pattern names).
fn paint_bytes(paint: &Paint) -> usize {
use std::mem::size_of;
use vectorcraft_doc::color::{FreeformPoint, GradientStop};
let own = size_of::<Paint>();
let Paint::Gradient(g) = paint else { return own };
let freeform = g
.freeform
.as_ref()
.map_or(0, |f| f.points.len() * size_of::<FreeformPoint>() + f.lines.iter().map(|l| l.len() * size_of::<usize>()).sum::<usize>());
own + size_of::<vectorcraft_doc::color::GradientPaint>() + g.gradient.stops.len() * size_of::<GradientStop>() + freeform
let name = |s: &Option<String>| s.as_ref().map_or(0, String::len);
let g = match paint {
Paint::Solid { swatch, .. } => return own + name(swatch),
Paint::Pattern { pattern, .. } => return own + pattern.len(),
Paint::Gradient(g) => g,
_ => return own,
};
let freeform = g.freeform.as_ref().map_or(0, |f| {
// Each line is a Vec: its header too, not only its indices.
f.points.len() * size_of::<FreeformPoint>()
+ f.lines.len() * size_of::<Vec<usize>>()
+ f.lines.iter().map(|l| l.len() * size_of::<usize>()).sum::<usize>()
});
let stops: usize = g.gradient.stops.iter().map(|s| size_of::<GradientStop>() + name(&s.swatch)).sum();
own + size_of::<vectorcraft_doc::color::GradientPaint>() + name(&g.swatch) + stops + freeform
}
/// Add `copies` copies of `paint` to the running byte total `used`; whether it still fits within
/// [`MAX_PAINT_COPIES`] (for the solid surfaces, whose items' copies count together).
pub(crate) fn paint_copies_add(used: &mut usize, copies: usize, paint: &Paint) -> bool {
*used = used.saturating_add(copies.saturating_mul(paint_bytes(paint)));
*used <= MAX_PAINT_COPIES
}
fn visible(fx: &[Effect]) -> bool {
+21
View File
@@ -484,6 +484,21 @@ fn paint_of(item: &AppearanceItem, member: f32) -> (Paint, f32, BlendMode) {
}
}
/// `item`'s paint, borrowed (for checks that must not copy it).
fn paint_ref(item: &AppearanceItem) -> &Paint {
match item {
AppearanceItem::Fill(f) => &f.paint,
AppearanceItem::Stroke(s) => &s.paint,
}
}
/// Paint copies `surface` makes for `facets`: two per facet (fill and seam stroke), six when colored
/// lights give each facet derived paints (`lighting::solid_surface`).
fn facet_copies(facets: &[Facet], paint: &Paint, colored: bool) -> usize {
let derived = colored && paint.color().is_some() && facets.iter().any(|f| f.rgb_gradient.is_some());
facets.len().saturating_mul(if derived { 6 } else { 2 })
}
/// The area `item` paints on `path`, and the rule that fills it: the path itself for a fill, the
/// stroke's outline (whose contours nest as its holes do) for a stroke.
fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData, FillRule) {
@@ -543,6 +558,8 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>>
let classic = fx.id == CLASSIC_EXTRUDE;
let colored = !classic && view_options(&p).has_colored_lights();
let masks = has_masks(shapes);
// The paint copies of every solid together: past the allowance the source path is drawn.
let mut paint_bytes = 0usize;
if fx.id == ROTATE {
// Every part turns about the centre of the object's own bounds (a group's members about
// the centre of theirs), so they stay together.
@@ -552,6 +569,9 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>>
let (paint, opacity, blend) = paint_of(item, s.opacity);
let (area, rule) = region(&s.path, s.rule, item);
let f = facets(ROTATE, &area, rule, b, &p).ok()?;
if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, &paint, colored), &paint) {
return None;
}
out.push((s.scopes.as_slice(), Arc::new(surface(n, f, &paint, opacity, blend, colored))));
}
}
@@ -569,6 +589,7 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>>
for ((item, opacity, _, _, scopes), f) in parts.iter().zip(built) {
let item: &AppearanceItem = item;
match f {
Ok(f) if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, paint_ref(item), colored), paint_ref(item)) => return None,
Ok(f) => solids.push((item, *opacity, *scopes, f)),
// A stroke's outline past the limits is left out; the fill's solid still draws.
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {}
+20 -3
View File
@@ -14,6 +14,10 @@ use super::{ARCLEN_ACCURACY, kink, push_seg, segments, subpaths, tangent};
/// More dashes than this (a tiny pattern on a long path) draws the line solid instead.
const MAX_DASHES: f64 = 200_000.0;
/// Pattern entries walked in all (every subpath and every corner restart starts the pattern over, and
/// zero-length entries cost no path length, so a long pattern is walked in full even on a short
/// run); past this the line is drawn solid. About 100x what real art needs.
const MAX_STEPS: f64 = 20_000_000.0;
/// A pattern whose longest entry is shorter than this can't advance along the path (each entry
/// ends within the generator's tolerance of where it starts), so it draws the line solid.
const MIN_ENTRY: f64 = 1e-6;
@@ -80,6 +84,10 @@ pub fn dash_kept(bp: &BezPath, d: &Dash, keep: &dyn Fn(&PathSeg) -> bool) -> Opt
if plans.iter().map(|p| p.periods).sum::<f64>() * pat.len() as f64 > MAX_DASHES {
return None;
}
let restarts = |p: &Plan| p.restart.iter().filter(|r| **r).count() as f64;
if plans.iter().map(|p| (p.periods.ceil().max(1.0) + restarts(p)) * pat.len() as f64).sum::<f64>() > MAX_STEPS {
return None;
}
// Where an exact pattern starts: `offset` into it.
let mut off = d.offset.rem_euclid(period);
let mut ix0 = 0;
@@ -89,8 +97,13 @@ pub fn dash_kept(bp: &BezPath, d: &Dash, keep: &dyn Fn(&PathSeg) -> bool) -> Opt
}
let phase = Phase { ix: ix0, rem: pat[ix0] - off, on: ix0 % 2 == 0 };
let mut out = Dashed::default();
// The estimates above trust the fitted scales; this counts the pattern entries actually walked,
// so entries that fit down to nothing can't keep a subpath looping.
let mut budget = MAX_STEPS as usize;
for plan in &plans {
dash_subpath(&mut out, plan, &pat, phase, keep);
if !dash_subpath(&mut out, plan, &pat, phase, keep, &mut budget) {
return None;
}
}
Some(out)
}
@@ -183,7 +196,8 @@ impl Piece {
}
}
fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep: &dyn Fn(&PathSeg) -> bool) {
/// Dash one subpath; `false` when `budget` (pattern entries left to walk, shared by all subpaths) runs out.
fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep: &dyn Fn(&PathSeg) -> bool, budget: &mut usize) -> bool {
let first_dot = out.dots.len();
let total: f64 = plan.lens.iter().sum();
// Fraction of the subpath's length at `x` along the plan.
@@ -244,6 +258,8 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep:
push_dot(out, first_dot, Dot { at: seg.eval(t), dir: tangent(seg, t), t: frac(acc + end) });
}
}
let Some(rest) = budget.checked_sub(1) else { return false };
*budget = rest;
cuts += 1;
s = end;
ph.ix = (ph.ix + 1) % pat.len();
@@ -273,7 +289,7 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep:
write(&mut out.path, &d.segs);
out.path.close_path();
out.spans.push((frac(0.0), frac(0.0) + 1.0));
return;
return true;
}
if starts_on && ends_on && dashes.len() > 1 {
// The dash running through the start point is one dash.
@@ -291,6 +307,7 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep:
let t0 = frac(d.start);
out.spans.push((t0, if total > 0.0 { t0 + (d.end - d.start) / total } else { t0 }));
}
true
}
/// Add `dot` unless the subpath's last dot (from index `first`) is already there: a run ending on
+14
View File
@@ -222,3 +222,17 @@ fn smooth_points_of_curves_stay_smooth_with_any_join() {
assert!((a - want).abs() / want < 0.005, "{join:?}: {a} vs {want}");
}
}
#[test]
fn a_long_pattern_restarted_on_many_short_subpaths_draws_solid() {
// Each subpath walks the zero-length entries in full although it is far shorter than a period.
let mut pattern = vec![0.0; 2000];
pattern[0] = 1.0;
let mut b = BezPath::new();
for i in 0..20_000 {
b.move_to((i as f64 * 2.0, 0.0));
b.line_to((i as f64 * 2.0 + 0.01, 0.0));
}
let d = Dash { pattern, offset: 0.0, align_corners: false };
assert!(dash(&b, &d).is_none());
}
+3 -2
View File
@@ -1101,8 +1101,9 @@ pub fn with_id(xml: &str, id: &str) -> Option<String> {
let f = filter_element(&doc).ok()?;
let at = f.attributes().find(|a| a.name() == "id" && a.namespace().is_none())?.range_value();
let (start, end) = (at.start.checked_sub(head)?, at.end.checked_sub(head)?);
// The value may sit in single quotes: those are escaped too.
let id = escaped(id).replace('\'', "&apos;");
// The value may sit in single quotes: those are escaped too. A tab, line feed or carriage
// return is written as a character reference, which attribute-value normalization keeps.
let id = escaped(id).replace('\'', "&apos;").replace('\t', "&#9;").replace('\n', "&#10;").replace('\r', "&#13;");
Some(format!("{}{}{}", xml.get(..start)?, id, xml.get(end..)?))
}
+54 -9
View File
@@ -218,34 +218,66 @@ fn visible(sources: &[TrapSource]) -> Vec<PathData> {
/// The pairs `(i, j)`, `i < j`, in ascending order, of `rects` (`None`: left out) that come within
/// `reach_x` of each other across x and `reach_y` across y. A sweep over the rects sorted by their
/// left edge, so shapes spread over the page cost about one check each rather than one per pair.
fn near_pairs(rects: &[Option<Rect>], reach_x: f64, reach_y: f64) -> Vec<(usize, usize)> {
/// `None` when there are more than [`MAX_NEAR_PAIRS`] (many shapes all within reach of each other
/// make the pair list grow with the square of their count) or the sweep examines more than
/// `MAX_NEAR_SCAN` pairs (many shapes sharing an x range, far apart in y).
fn near_pairs(rects: &[Option<Rect>], reach_x: f64, reach_y: f64) -> Option<Vec<(usize, usize)>> {
near_pairs_within(rects, reach_x, reach_y, MAX_NEAR_SCAN)
}
fn near_pairs_within(rects: &[Option<Rect>], reach_x: f64, reach_y: f64, max_scan: usize) -> Option<Vec<(usize, usize)>> {
let mut order: Vec<(usize, Rect)> = rects.iter().enumerate().filter_map(|(i, r)| r.map(|r| (i, r))).collect();
order.sort_unstable_by(|a, b| a.1.x0.total_cmp(&b.1.x0).then(a.0.cmp(&b.0)));
let mut pairs = vec![];
let mut scanned = 0usize;
for (k, &(i, a)) in order.iter().enumerate() {
// Later rects start at or right of `a`, so only how far right they start can rule them out.
for &(j, b) in order[k + 1..].iter().take_while(|(_, b)| b.x0 <= a.x1 + reach_x) {
// Rects sharing an x range but far apart in y are examined without being stored.
scanned += 1;
if scanned > max_scan {
return None;
}
if b.y0 <= a.y1 + reach_y && a.y0 <= b.y1 + reach_y {
if pairs.len() >= MAX_NEAR_PAIRS {
return None;
}
pairs.push((i.min(j), i.max(j)));
}
}
}
pairs.sort_unstable();
pairs
Some(pairs)
}
/// Most pairs the sweep in `near_pairs` examines, stored or not (a plain bounds check each).
const MAX_NEAR_SCAN: usize = 1 << 27;
/// Most candidate pairs one trap evaluation keeps; past it no traps are made (the art draws as is).
/// Each pair costs a boolean trap band, so real art stays far below this.
pub const MAX_NEAR_PAIRS: usize = 1 << 20;
/// The traps between `sources` (back to front) with options `o`: for each two sources of
/// different solid colours whose visible parts meet, the strip of the darker one's visible part
/// next to the lighter one's (the other way round with [`TrapOptions::reverse`]), painted per
/// [`trap_paint`]. Empty when no visible edges of different colours meet.
/// [`trap_paint`]. Empty when no visible edges of different colours meet, or when there are more
/// than [`MAX_NEAR_PAIRS`] candidate pairs or the pair scan is too long (see [`try_traps`] to tell
/// those apart).
pub fn traps(sources: &[TrapSource], o: &TrapOptions) -> Vec<TrapPiece> {
try_traps(sources, o).unwrap_or_default()
}
/// [`traps`], but `None` when there are more than [`MAX_NEAR_PAIRS`] candidate pairs or the scan for
/// them is too long (`MAX_NEAR_SCAN` pairs examined), as opposed to
/// `Some` of an empty list when no edges meet.
pub fn try_traps(sources: &[TrapSource], o: &TrapOptions) -> Option<Vec<TrapPiece>> {
let colors: Vec<Option<Color>> = sources.iter().map(|s| trap_color(&s.fill)).collect();
if colors.iter().flatten().count() < 2 {
return vec![];
return Some(vec![]);
}
let (w, r) = (o.thickness, o.height_width);
if !(w.is_finite() && w > 0.0 && r.is_finite() && r > 0.0) {
return vec![]; // `trap_band` cuts nothing with these.
return Some(vec![]); // `trap_band` cuts nothing with these.
}
let vis = visible(sources);
// Only pairs whose visible parts come within the trap's reach can trap (`trap_band` rejects
@@ -257,7 +289,8 @@ pub fn traps(sources: &[TrapSource], o: &TrapOptions) -> Vec<TrapPiece> {
let extent = rects.iter().flatten().flat_map(|b| [b.x0, b.y0, b.x1, b.y1]).fold(0.0_f64, |m, v| m.max(v.abs()));
let hair = 1e-9 * (1.0 + extent + w * r.max(1.0));
let mut out = vec![];
for (i, j) in near_pairs(&rects, w + hair, w * r + hair) {
let pairs = near_pairs(&rects, w + hair, w * r + hair)?;
for (i, j) in pairs {
let (Some(ci), Some(cj)) = (colors[i], colors[j]) else { continue };
if vis[i].is_empty() || vis[j].is_empty() || same_color(&sources[i].fill, &sources[j].fill, ci, cj) {
continue;
@@ -276,7 +309,7 @@ pub fn traps(sources: &[TrapSource], o: &TrapOptions) -> Vec<TrapPiece> {
let Some(color) = colors[from] else { continue };
out.push(TrapPiece { path: band, paint: trap_paint(&sources[from].fill, color, o), from, into });
}
out
Some(out)
}
/// A trap's appearance: one overprinting fill of `paint`, no stroke.
@@ -379,10 +412,19 @@ mod tests {
assert!(near(po::area(&t[0].path, FillRule::NonZero), 22.0 * 22.0 - 400.0));
}
#[test]
fn the_pair_scan_is_bounded_even_when_no_pair_is_near() {
// Eleven rects share an x range but lie far apart in y: 55 pairs are examined, none stored.
let rects: Vec<Option<Rect>> = (0..11).map(|k| Some(Rect::new(0.0, k as f64 * 1000.0, 10.0, k as f64 * 1000.0 + 10.0))).collect();
assert_eq!(near_pairs_within(&rects, 1.0, 1.0, 100), Some(vec![]));
assert_eq!(near_pairs_within(&rects, 1.0, 1.0, 54), None);
}
#[test]
fn no_trap_without_adjoining_edges_of_different_colours() {
let apart = vec![src(Rect::new(0.0, 0.0, 10.0, 10.0), yellow()), src(Rect::new(30.0, 0.0, 40.0, 10.0), blue())];
assert!(traps(&apart, &TrapOptions::default()).is_empty());
assert_eq!(try_traps(&apart, &TrapOptions::default()).map(|t| t.len()), Some(0), "no edges meet: empty, not over the cap");
let same = vec![src(Rect::new(0.0, 0.0, 10.0, 10.0), blue()), src(Rect::new(10.0, 0.0, 20.0, 10.0), blue())];
assert!(traps(&same, &TrapOptions::default()).is_empty());
let mut unfilled = abutting();
@@ -431,10 +473,13 @@ mod tests {
fn only_shapes_within_reach_are_paired() {
let s = many_apart_and_one_abutting();
let rects: Vec<Option<Rect>> = s.iter().map(|t| t.path.bounds()).collect();
assert_eq!(near_pairs(&rects, 1.0, 1.0), vec![(0, 2000)], "the squares 10 pt apart are never paired");
assert_eq!(near_pairs(&rects, 1.0, 1.0), Some(vec![(0, 2000)]), "the squares 10 pt apart are never paired");
// Reaching 10 pt, neighbours in a row pair, in ascending order; left-out rects never do.
let row: Vec<Option<Rect>> = [3, 1, 0, 2].iter().map(|&k| s[k].path.bounds()).chain([None]).collect();
assert_eq!(near_pairs(&row, 10.0, 10.0), vec![(0, 3), (1, 2), (1, 3)]);
assert_eq!(near_pairs(&row, 10.0, 10.0), Some(vec![(0, 3), (1, 2), (1, 3)]));
// Past the allowance the pair list stops growing instead of being built.
let packed = vec![Some(Rect::new(0.0, 0.0, 1.0, 1.0)); 1500];
assert_eq!(near_pairs(&packed, 1.0, 1.0), None);
let t = traps(&s, &TrapOptions { thickness: 1.0, ..Default::default() });
assert_eq!(t.len(), 1, "only the abutting pair traps");
assert_eq!((t[0].from, t[0].into), (2000, 0));
+4 -3
View File
@@ -774,8 +774,9 @@ fn divide_dashes(s: &mut Session, p: &Value) -> Result<Value> {
continue;
}
let only = (segs.len() < sp.segment_count()).then_some(&segs);
let out = pt::divide_dashes(sp, only, spec, MAX_PIECES.saturating_sub(made))
.ok_or_else(|| bad(C, "that makes too many pieces (at most 100000): make the dashes longer"))?;
let out = pt::divide_dashes(sp, only, spec, MAX_PIECES.saturating_sub(made)).ok_or_else(|| {
bad(C, "that makes too many pieces (at most 100000) or dash steps: simplify the dash pattern or make the dashes longer")
})?;
made += out.len();
for (piece, kind) in out.into_iter().filter(|(_, k)| !(remove_gaps && *k == pt::Piece::Gap)) {
dashes += usize::from(kind == pt::Piece::Dash);
@@ -825,7 +826,7 @@ fn dashed_paths(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
fn expand_dashes(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.expandDashes";
let too_many = || bad(C, "that makes too many pieces (at most 100000): make the dashes longer");
let too_many = || bad(C, "that makes too many pieces (at most 100000) or dash steps: simplify the dash pattern or make the dashes longer");
let roots = checked_ids_or_selection(s, p, C)?;
let d = &s.doc()?.doc;
// Each dashed path and its dashes, as the canvas draws them.
+1 -1
View File
@@ -496,7 +496,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
// nothing to apply is no edit, and a click that only applies brushes the document has is
// named for that.
let mut probe = (*st.doc).clone();
let imported = clip.import_into(&mut probe, &choices, false);
let imported = clip.import_into(&mut probe, &choices, false)?;
let doc_names: Vec<String> = picked.iter().map(|n| target(&imported.renamed, n)).collect();
let paths = if apply { brush_targets(&st.doc, &roots(s, p)?) } else { vec![] };
let paint = stroke_paint(s);
+54 -16
View File
@@ -28,7 +28,7 @@ use vectorcraft_doc::{
use super::super::brushsym::{set_natural_size, stored_natural_size};
use super::super::patterncmds::add_pattern;
use super::super::{Result, bad, unique_name};
use super::super::{EngineError, Result, bad, unique_name};
use super::Clipboard;
/// A resource kind objects refer to (graphic styles by id, the others by name).
@@ -165,6 +165,23 @@ fn node_filters(n: &Node, out: &mut BTreeSet<(String, String)>) {
});
}
/// The names the SVG filter effects of appearance `a` give (its own and its fills' and strokes').
fn appearance_filter_names(a: &Appearance, out: &mut BTreeSet<String>) {
let items = a.items.iter().flat_map(|i| i.effects().iter());
let named = a.effects.iter().chain(items).filter(|e| e.id == Appearance::SVG_FILTER);
out.extend(named.filter_map(|e| e.params.get("name").and_then(Value::as_str)).map(str::to_string));
}
/// The names the SVG filter effects in the subtree of `n` give (opacity-mask art included).
fn node_filter_names(n: &Node, out: &mut BTreeSet<String>) {
n.walk(&mut |m| {
appearance_filter_names(&m.appearance, out);
if let Some(mask) = &m.extras().mask {
node_filter_names(&mask.art, out);
}
});
}
/// A global or spot colour of its own (not a tint of another swatch): what colours link to.
fn is_base(w: &Swatch) -> bool {
(w.global || w.spot) && matches!(w.paint, Paint::Solid { swatch: None, .. })
@@ -1038,8 +1055,9 @@ impl Clipboard {
}
/// Add the resources the objects use to `d` (inside the paste's undo step) and work out how
/// pasted copies relink to them.
pub(crate) fn import_into(&self, d: &mut Document, choices: &SwatchChoices, same_doc: bool) -> Imported {
/// pasted copies relink to them. An error, with `d` unchanged, when a pasted SVG filter finds no
/// free name ([`Self::filter_links`]).
pub(crate) fn import_into(&self, d: &mut Document, choices: &SwatchChoices, same_doc: bool) -> Result<Imported> {
self.import_used(d, &self.used(), choices, same_doc, false)
}
@@ -1048,9 +1066,9 @@ impl Clipboard {
/// each style, symbol, pattern and brush comes after the styles, symbols, patterns and brushes it uses
/// ([`use_order`]), so that it is compared and relinked with their names here, and each loop
/// of definitions that use each other is matched on its own ([`loop_groups`]).
fn import_used(&self, d: &mut Document, used: &Used, choices: &SwatchChoices, same_doc: bool, in_use_order: bool) -> Imported {
fn import_used(&self, d: &mut Document, used: &Used, choices: &SwatchChoices, same_doc: bool, in_use_order: bool) -> Result<Imported> {
let mut out = Imported::default();
out.relink.filters = self.filter_links(d, same_doc);
out.relink.filters = self.filter_links(d, same_doc)?;
self.import_images(d, used, same_doc, &mut out);
self.import_swatches(d, used, choices, same_doc, &mut out);
// Brushes whose art shows no symbols or patterns, before the symbols and patterns whose art
@@ -1213,16 +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. When that name would
/// 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.
fn filter_links(&self, d: &Document, same_doc: bool) -> BTreeMap<(String, String), (String, String)> {
/// a free letter or digit; the filter's id is always its name. An error when no free name fits.
fn filter_links(&self, d: &Document, same_doc: bool) -> Result<BTreeMap<(String, String), (String, String)>> {
let mut found = BTreeSet::new();
for n in &self.nodes {
node_filters(n, &mut found);
@@ -1240,12 +1258,28 @@ impl Clipboard {
appearance_filters(&g.appearance, &mut found);
}
let mut taken: BTreeSet<String> = d.svg_filters.iter().map(|f| f.name.clone()).chain(found.iter().map(|(n, _)| n.clone())).collect();
let mut effects_read = false;
let mut links = BTreeMap::new();
for (name, xml) in found {
let Some(held) = d.svg_filters.iter().find(|f| f.name == name).filter(|f| f.xml != xml.trim()) else { continue };
let to = if same_doc {
(name.clone(), held.xml.clone())
} else {
// The names the document's effects give are taken too, those whose filter was
// deleted included: defining a filter of that name later changes every effect that
// names it. They are read at the first rename only.
if !effects_read {
effects_read = true;
for root in d.art_roots() {
node_filter_names(root, &mut taken);
}
for a in vectorcraft_brush::library(d).iter().flat_map(Brush::art) {
node_filter_names(a, &mut taken);
}
for g in &d.graphic_styles {
appearance_filter_names(&g.appearance, &mut taken);
}
}
// Free names made from the starts of the name, longest first, then free letters and
// digits: the first under which the filter still draws.
let mut ends: Vec<usize> = name.char_indices().map(|(i, _)| i).skip(1).collect();
@@ -1255,16 +1289,20 @@ impl Clipboard {
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())?;
.filter(|x| vectorcraft_render::effects::svg_filter::check(x).is_ok_and(|id| id == new))?;
Some((new, drawn))
});
let Some((new, drawn)) = renamed else { continue };
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());
(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.
@@ -1379,13 +1417,13 @@ pub(crate) struct LoadedStyle {
/// a pattern added under a free name and a pattern follows a style renamed on the way in. A style
/// `d` has under its name, or a numbered copy of it ("Body 2"), that is the same once relinked is
/// used; any other is added under a free name. → each style of `src` placed, in order, character
/// styles first.
pub(crate) fn import_text_styles(src: &Document, d: &mut Document, chars: bool, paras: bool) -> Vec<LoadedStyle> {
/// styles first. An error, as [`Clipboard::import_into`] gives.
pub(crate) fn import_text_styles(src: &Document, d: &mut Document, chars: bool, paras: bool) -> Result<Vec<LoadedStyle>> {
let library = OnceCell::new();
let used = Used::of_text_styles(&Defs::of(src, &library), chars, paras);
let clip = Clipboard::with_used(src, &used, &library, vec![]);
let before = [&d.char_styles, &d.para_styles].map(|l| l.iter().map(|s| s.name.clone()).collect::<BTreeSet<_>>());
let out = clip.import_used(d, &used, &SwatchChoices::default(), false, true);
let out = clip.import_used(d, &used, &SwatchChoices::default(), false, true)?;
let mut placed: Vec<LoadedStyle> = vec![];
for (para, r, list) in [(false, Res::CharStyle, &clip.char_styles), (true, Res::ParaStyle, &clip.para_styles)] {
for s in list {
@@ -1395,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
+1 -1
View File
@@ -1332,7 +1332,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
vec![Affine::IDENTITY]
};
let (ids, imported, artboards, into) = s.edit(mode.label(), |d, sel| {
let imported = clip.import_into(d, choices, same_doc);
let imported = clip.import_into(d, choices, same_doc)?;
// Draw Inside: the clip group of the object drawn into, made on the first paste.
let into = inside.map(|t| super::create::draw_inside_group(d, t)).transpose()?;
let artboards: Vec<usize> = boards
+2 -2
View File
@@ -1303,7 +1303,7 @@ fn use_pattern(s: &mut Session, data: &Value, stroke: bool) -> Result<String> {
let choices = SwatchChoices::default();
// The pattern's name in the document, worked out on a copy: nothing to add is no edit.
let mut probe = (*s.doc()?.doc).clone();
let imported = clip.import_into(&mut probe, &choices, false);
let imported = clip.import_into(&mut probe, &choices, false)?;
let renamed = imported.renamed.iter().find(|r| r["kind"] == "pattern" && r["from"] == name.as_str()).and_then(|r| r["to"].as_str());
let target = renamed.unwrap_or(&name).to_string();
let paint = vectorcraft_doc::pattern::pattern_paint(&target);
@@ -1315,7 +1315,7 @@ fn use_pattern(s: &mut Session, data: &Value, stroke: bool) -> Result<String> {
in_one_step(s, |s| {
if added {
s.edit("Add to Swatches", |d, _| {
clip.import_into(d, &choices, false);
clip.import_into(d, &choices, false)?;
if !has_swatch(d) {
let swatch = d.free_swatch_name(&target);
d.swatches.push(vectorcraft_color::Swatch { name: swatch, paint: paint.clone(), global: false, spot: false });
+5 -3
View File
@@ -670,9 +670,11 @@ fn render_pixels(
opts: &vectorcraft_render::RenderOptions,
color: RasterColorModel,
) -> Result<(vectorcraft_render::Rendered, Option<&'static str>)> {
// No side longer than the renderer draws: it would cut the image off there.
let side = f64::from(vectorcraft_render::MAX_RASTER_SIDE);
if region.width() * region.height() * scale * scale > MAX_PIXELS || region.width() * scale > side || region.height() * scale > side {
// No side longer than the renderer draws: it would cut the image off there. The sides are
// measured in the renderer's whole pixels.
let side = vectorcraft_render::MAX_RASTER_SIDE;
let (w, h) = vectorcraft_render::region_pixels(region, scale);
if region.width() * region.height() * scale * scale > MAX_PIXELS || w > side || h > side {
return Err(EngineError::Other("image would be too large; lower the resolution".into()));
}
let mut r = vectorcraft_render::Renderer::new();
+7 -2
View File
@@ -719,7 +719,7 @@ fn run_pf(s: &mut Session, p: &Value, op: PathfinderOp) -> Result<Value> {
/// Pathfinder panel › Trap ([`vectorcraft_render::effects::trap`]): the traps between the selected
/// objects' leaf shapes, as one group of overprinting paths above the topmost selected object.
fn trap(s: &mut Session, p: &Value) -> Result<Value> {
use vectorcraft_render::effects::trap::{TRAP_LABEL, TrapOptions, TrapSource, trap_appearance, traps};
use vectorcraft_render::effects::trap::{TRAP_LABEL, TrapOptions, TrapSource, trap_appearance, try_traps};
const ID: &str = "object.pathfinder.trap";
let mut q = p.clone();
if let Some(t) = length_given(p, "thickness", ID)? {
@@ -758,7 +758,12 @@ fn trap(s: &mut Session, p: &Value) -> Result<Value> {
if sources.iter().filter(|s| s.fill.color().is_some()).count() < 2 {
return Err(EngineError::Other("Trap: select two or more objects filled with solid colours".into()));
}
let made = traps(&sources, &options);
let Some(made) = try_traps(&sources, &options) else {
return Err(EngineError::Other(format!(
"Trap: more than {} pairs of objects lie close enough to trap, or too many objects share a horizontal range; trap fewer objects at a time",
vectorcraft_render::effects::trap::MAX_NEAR_PAIRS
)));
};
if made.is_empty() {
return Err(EngineError::Other("Trap: no edges of different colours meet among the selected objects".into()));
}
+4 -3
View File
@@ -10,6 +10,7 @@ use vectorcraft_doc::pattern::pattern_paint;
use vectorcraft_doc::{Appearance, Document, Node, NodeId, PatternDef, TileType};
use vectorcraft_geom::{PathData, Point, Rect, SubPath, shapes};
use super::Result;
use super::clipboard::Clipboard;
use super::clipboard::resources::SwatchChoices;
use super::swatchlib::LibraryInfo;
@@ -84,11 +85,11 @@ pub fn swatch_source(w: &Swatch) -> Option<Arc<Document>> {
/// Add pattern `name` of library `lib` to `d` with the resources its art uses, as pasting art
/// painted with it does: the same pattern there already is used, and a name another pattern or
/// swatch has gets a number → the pattern's name in `d`.
pub(crate) fn import(d: &mut Document, lib: &Document, name: &str) -> String {
pub(crate) fn import(d: &mut Document, lib: &Document, name: &str) -> Result<String> {
let user = Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(pattern_paint(name), Paint::None, 0.0));
let imported = Clipboard::gather(lib, vec![user]).import_into(d, &SwatchChoices::default(), false);
let imported = Clipboard::gather(lib, vec![user]).import_into(d, &SwatchChoices::default(), false)?;
let renamed = imported.renamed.iter().find(|r| r["kind"] == "pattern" && r["from"] == name).and_then(|r| r["to"].as_str());
renamed.unwrap_or(name).to_string()
Ok(renamed.unwrap_or(name).to_string())
}
/// The pattern of `d` that is pattern `name` of library `lib`: the same tile options and art under
+3 -1
View File
@@ -161,7 +161,9 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(param("give exactly one of `path` and `source`")),
};
let name = given_name.map(str::to_string).or_else(|| path.map(super::fileio::file_name)).unwrap_or_else(|| "script".to_string());
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}));
+1 -1
View File
@@ -753,7 +753,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
if let (Paint::Pattern { pattern, .. }, Some(src)) = (&w.paint, &patterns) {
// The pattern comes with its swatch; a pattern the document has without one
// gets one.
let target = patternlib::import(d, src, pattern);
let target = patternlib::import(d, src, pattern)?;
let name = match patternlib::pattern_swatch(d, &target) {
Some(name) => name,
None => {
+2 -2
View File
@@ -405,7 +405,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
};
// Where the symbols land, worked out on a copy: nothing to add and nothing to place is no edit.
let mut probe = (*st.doc).clone();
let imported = clip.import_into(&mut probe, &choices, false);
let imported = clip.import_into(&mut probe, &choices, false)?;
let doc_names: Vec<String> = picked.iter().map(|n| target(&imported.renamed, n)).collect();
let centre = st.doc.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO);
let at = if place { Some(Point::new(number_or(p, "x", centre.x, C)?, number_or(p, "y", centre.y, C)?)) } else { None };
@@ -413,7 +413,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
let mut out = json!({ "library": info.id });
if imported.added > 0 || place {
let placed = s.edit("Add to Symbols", |d, sel| {
let imported = clip.import_into(d, &choices, false);
let imported = clip.import_into(d, &choices, false)?;
// Every symbol brought in (nested ones too) takes its Symbol Options along.
for sym in &clip.symbols {
let to = target(&imported.renamed, &sym.name);
+2 -2
View File
@@ -976,12 +976,12 @@ fn load(s: &mut Session, p: &Value, kinds: &[Kind]) -> Result<Value> {
}
let (chars, paras) = (kinds.contains(&Kind::Char), kinds.contains(&Kind::Para));
let mut trial = (*s.doc()?.doc).clone();
let mut placed = import_text_styles(&src, &mut trial, chars, paras);
let mut placed = import_text_styles(&src, &mut trial, chars, paras)?;
// An undo step only when the document changes. The step takes the document the import made,
// unless the edit first ends a typing session, which can change the document it starts from.
if trial != *s.doc()?.doc {
if super::typing_in_progress(s) {
placed = s.edit(label, |d, _| Ok(import_text_styles(&src, d, chars, paras)))?;
placed = s.edit(label, |d, _| import_text_styles(&src, d, chars, paras))?;
} else {
s.edit(label, |d, _| {
*d = trial;
+7 -7
View File
@@ -1105,7 +1105,7 @@ fn self_referencing_pattern_is_not_added_again() {
let different = dst.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone();
dst.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [different]})).unwrap();
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
super::super::clipboard::resources::import_text_styles(&source, target, true, true);
super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap();
assert!(dst.doc().unwrap().doc.pattern("Dots 2").is_some());
assert!(
dst.doc().unwrap().doc.pattern("Dots 2").unwrap().art[0]
@@ -1116,7 +1116,7 @@ fn self_referencing_pattern_is_not_added_again() {
);
let before = dst.doc().unwrap().doc.clone();
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true);
let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap();
assert!(again.iter().all(|s| !s.added));
assert_eq!(dst.doc().unwrap().doc, before);
}
@@ -1148,13 +1148,13 @@ fn separate_self_referencing_patterns_are_not_added_again() {
dst.execute("object.pattern.make", &json!({"name": format!("Dots{k}"), "edit": false, "ids": [different]})).unwrap();
}
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
super::super::clipboard::resources::import_text_styles(&source, target, true, true);
super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap();
for k in 0..9 {
assert!(dst.doc().unwrap().doc.pattern(&format!("Dots{k} 2")).is_some(), "{k}");
}
let before = dst.doc().unwrap().doc.clone();
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true);
let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap();
assert!(again.iter().all(|s| !s.added));
assert_eq!(dst.doc().unwrap().doc.patterns.len(), before.patterns.len());
assert_eq!(dst.doc().unwrap().doc, before);
@@ -1162,7 +1162,7 @@ fn separate_self_referencing_patterns_are_not_added_again() {
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
target.patterns.retain(|p| !p.name.starts_with("Dots8"));
let left = target.patterns.len();
let third = super::super::clipboard::resources::import_text_styles(&source, target, true, true);
let third = super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap();
assert_eq!(dst.doc().unwrap().doc.patterns.len(), left + 1);
assert!(third.iter().filter(|s| s.name != "Patterned8" && !s.name.starts_with("Patterned8 ")).all(|s| !s.added));
}
@@ -1195,13 +1195,13 @@ fn a_long_loop_of_patterns_is_not_added_again() {
dst.execute("object.pattern.make", &json!({"name": format!("Dots{k}"), "edit": false, "ids": [different]})).unwrap();
}
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
super::super::clipboard::resources::import_text_styles(&source, target, true, true);
super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap();
for k in 0..9 {
assert!(dst.doc().unwrap().doc.pattern(&format!("Dots{k} 2")).is_some(), "{k}");
}
let before = dst.doc().unwrap().doc.clone();
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true);
let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap();
assert!(again.iter().all(|s| !s.added));
assert_eq!(dst.doc().unwrap().doc.patterns.len(), before.patterns.len());
assert_eq!(dst.doc().unwrap().doc, before);
+2 -1
View File
@@ -106,7 +106,8 @@ impl Entry {
Entry::End(e) => format!("ended: {}", e.id()),
Entry::Report { ms, host_calls, slowest } => {
let time = ms.map_or_else(|| "time unavailable".to_string(), |ms| format!("{ms} ms"));
let mut s = format!("{time}, {host_calls} host calls");
let calls = if *host_calls == 1 { "host call" } else { "host calls" };
let mut s = format!("{time}, {host_calls} {calls}");
if !slowest.is_empty() {
let list: Vec<String> = slowest.iter().map(|c| format!("{} ×{} {} ms", c.id, c.calls, c.total_ms)).collect();
s.push_str("; slowest: ");
+307
View File
@@ -0,0 +1,307 @@
//! The members of a script's `$` object besides `$.write`, `$.writeln`, `$.evalFile`, `$.fileName`
//! and `$.includePath`, from the Dollar object
//! page (<https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/>), and
//! the locale the global `localize` reads
//! (<https://extendscript.docsforadobe.dev/extendscript-tools-features/localizing-extendscript-strings/>).
//!
//! Every value here belongs to one run: a run starts with the locale of the interface, the
//! environment values of [`ENV_NAMES`] read from the process, localization off and a timer at zero,
//! and what a script changes ends with its run. `$.setenv` never changes the process's environment.
//!
//! These are VectorCraft's own values and rules: `$.appEncoding` is always UTF-8; `$.build` and
//! `$.version` give VectorCraft's version, `$.engineName` is `VectorCraft` and `$.buildDate` is
//! `null`; `$.flags` takes only 512, which turns localization on; `$.level` and `$.memCache` are 0
//! and `$.screens` is empty; `$.os` names the system without its version; `$.strict` is whether the
//! realm reads scripts as strict mode code; `$.bp` and `$.colorPicker` throw, since scripts have no
//! debugger and cannot show a dialog; `$.gc` does nothing, since the interpreter collects its own
//! garbage; `$.getenv` and `$.setenv` know only [`ENV_NAMES`], with values of at most
//! [`MAX_ENV_BYTES`] bytes of letters, digits and `_ . - @`; and `$.sleep` waits at most
//! [`MAX_SLEEP_MS`] milliseconds, between two frames of the app, and does not wait on the web,
//! which has no clock.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::time::Duration;
use vectorcraft_js::{Data, HostError, HostItem, Localization, ScriptItem};
use super::{Host, function};
/// The longest `$.sleep`, in milliseconds.
pub const MAX_SLEEP_MS: f64 = 1_000.0;
/// The environment variables `$.getenv` reads and `$.setenv` sets.
pub const ENV_NAMES: &[&str] = &["LANG", "LC_MESSAGES", "LC_ALL", "OS"];
/// The most bytes of an environment value.
pub const MAX_ENV_BYTES: usize = 64;
/// The value of `$.flags` that turns localization on, the only one a script may set.
pub const LOCALIZE_FLAG: f64 = 512.0;
/// What `$` knows of a run.
pub(crate) struct RunInfo {
/// The locale the run starts with, such as `de_DE`.
pub locale: String,
/// The realm reads scripts as strict mode code.
pub strict: bool,
}
/// The locale for an interface language code such as `de`, `pt-br` or `zh-hant`: the language,
/// with its region after `_` where the code gives one (`pt_BR`; `zh_CN` for `zh-hans` and `zh_TW`
/// for `zh-hant`); `en` for a code that is not a language.
pub(crate) fn ui_locale(code: &str) -> String {
let mut parts = code.split(['-', '_']);
let language = parts.next().unwrap_or_default().to_ascii_lowercase();
if !(2..=3).contains(&language.len()) || !language.bytes().all(|b| b.is_ascii_lowercase()) {
return "en".into();
}
let region = match parts.next().map(str::to_ascii_lowercase).as_deref() {
Some("hans") => Some("CN".to_string()),
Some("hant") => Some("TW".to_string()),
Some(r) if r.len() == 2 && r.bytes().all(|b| b.is_ascii_lowercase()) => Some(r.to_ascii_uppercase()),
_ => None,
};
region.map_or(language.clone(), |r| format!("{language}_{r}"))
}
/// A locale a script gives `$.locale`, as `ll` or `ll_RR`: a language of two or three letters and
/// an optional region of two letters or three digits, joined by `_` or `-`, in any case.
fn script_locale(text: &str) -> Option<String> {
let mut parts = text.split(['-', '_']);
let language = parts.next()?;
let region = parts.next();
if parts.next().is_some() || !(2..=3).contains(&language.len()) || !language.bytes().all(|b| b.is_ascii_alphabetic()) {
return None;
}
let language = language.to_ascii_lowercase();
match region {
None => Some(language),
Some(r) if r.len() == 2 && r.bytes().all(|b| b.is_ascii_alphabetic()) => Some(format!("{language}_{}", r.to_ascii_uppercase())),
Some(r) if r.len() == 3 && r.bytes().all(|b| b.is_ascii_digit()) => Some(format!("{language}_{r}")),
Some(_) => None,
}
}
/// The languages whose numbers are written with a decimal comma (VectorCraft's own list).
const DECIMAL_COMMA: &[&str] = &[
"bg", "ca", "cs", "da", "de", "el", "es", "et", "fi", "fr", "hr", "hu", "id", "it", "lt", "lv", "nb", "nl", "no", "pl", "pt", "ro", "ru", "sk",
"sl", "sr", "sv", "tr", "uk", "vi",
];
/// The character `locale` writes before a number's fraction.
pub(crate) fn decimal_point(locale: &str) -> &'static str {
let language = locale.split('_').next().unwrap_or_default();
if DECIMAL_COMMA.contains(&language) { "," } else { "." }
}
/// The platform suffix of a localization object's names on this system.
pub(crate) fn platform() -> Option<&'static str> {
if cfg!(target_arch = "wasm32") {
None
} else if cfg!(windows) {
Some("Win")
} else if cfg!(target_os = "macos") {
Some("Mac")
} else {
Some("Unix")
}
}
/// `$.os`: the system, without its version.
fn os() -> &'static str {
if cfg!(target_arch = "wasm32") {
"Web"
} else if cfg!(windows) {
"Windows"
} else if cfg!(target_os = "macos") {
"macOS"
} else if cfg!(target_os = "linux") {
"Linux"
} else if cfg!(target_os = "freebsd") {
"FreeBSD"
} else {
"Unix"
}
}
/// Is `value` one an environment value may be?
fn env_value_ok(value: &str) -> bool {
value.len() <= MAX_ENV_BYTES && value.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-' | b'@'))
}
/// The values of [`ENV_NAMES`] in the process's environment, each left out when it is not one an
/// environment value may be.
fn process_env() -> Vec<(&'static str, Option<String>)> {
ENV_NAMES.iter().map(|&n| (n, std::env::var(n).ok().filter(|v| env_value_ok(v)))).collect()
}
fn one_string(args: &[Data], name: &str) -> Result<String, HostError> {
match args.first() {
Some(Data::String(s)) => Ok(s.clone()),
_ => Err(HostError::Type(format!("$.{name}: give a name, as a string"))),
}
}
fn value(v: Data) -> HostItem {
HostItem::Value(v)
}
fn text(s: impl Into<String>) -> Data {
Data::String(s.into())
}
/// The members `$` has besides `write`, `writeln`, `evalFile`, `fileName` and `includePath`.
pub(super) fn members(host: &Rc<Host>, info: RunInfo, localization: &Rc<Localization>) -> Vec<(String, HostItem)> {
let version = env!("CARGO_PKG_VERSION");
let host_locale = info.locale.clone();
localization.set_locale(&info.locale);
localization.set_platform(platform());
let env = Rc::new(RefCell::new(process_env()));
let timer = Rc::new(Cell::new(Duration::ZERO));
let getter = |f: Rc<dyn Fn() -> Data>| HostItem::Getter(function(move |_| Ok(f())));
let property = |get: Rc<dyn Fn() -> Data>, set: Rc<dyn Fn(Data) -> Result<(), HostError>>| HostItem::Property {
get: function(move |_| Ok(get())),
set: function(move |mut a| {
set(if a.is_empty() { Data::Undefined } else { a.swap_remove(0) })?;
Ok(Data::Undefined)
}),
};
let fail = |message: &'static str| HostItem::Function(function(move |_| Err(HostError::Error(message.to_string()))));
let locale = {
let (l, s) = (localization.clone(), localization.clone());
property(
Rc::new(move || text(l.locale())),
Rc::new(move |v| {
let locale = match v {
Data::Null | Data::Undefined => host_locale.clone(),
Data::String(t) if t.is_empty() => host_locale.clone(),
Data::String(t) => script_locale(&t).ok_or_else(|| HostError::Range(BAD_LOCALE.to_string()))?,
_ => return Err(HostError::Type(BAD_LOCALE.to_string())),
};
s.set_locale(&locale);
Ok(())
}),
)
};
let localize = {
let (l, s) = (localization.clone(), localization.clone());
property(
Rc::new(move || Data::Bool(l.enabled())),
Rc::new(move |v| match v {
Data::Bool(on) => {
s.set_enabled(on);
Ok(())
}
_ => Err(HostError::Type("$.localize: give true or false".into())),
}),
)
};
let flags = {
let (l, s) = (localization.clone(), localization.clone());
property(
Rc::new(move || Data::Number(if l.enabled() { LOCALIZE_FLAG } else { 0.0 })),
Rc::new(move |v| match v {
Data::Number(n) if n == 0.0 || n == LOCALIZE_FLAG => {
s.set_enabled(n == LOCALIZE_FLAG);
Ok(())
}
Data::Number(_) => Err(HostError::Range("$.flags: only 512, which turns localization on, or 0 can be set".into())),
_ => Err(HostError::Type("$.flags: give a number".into())),
}),
)
};
let decimal = {
let l = localization.clone();
getter(Rc::new(move || text(decimal_point(&l.locale()))))
};
let app_encoding = property(
Rc::new(|| text("UTF-8")),
Rc::new(|v| match v {
Data::String(t) if t.eq_ignore_ascii_case("utf-8") || t.eq_ignore_ascii_case("utf8") => Ok(()),
Data::String(_) => Err(HostError::Range("$.appEncoding: scripts here read and write UTF-8 only".into())),
_ => Err(HostError::Type("$.appEncoding: give an encoding's name, as a string".into())),
}),
);
let mem_cache =
property(Rc::new(|| Data::Number(0.0)), Rc::new(|_| Err(HostError::Error("$.memCache: the memory cache size cannot be set".into()))));
let hires_timer = {
let (h, last) = (host.clone(), timer.clone());
getter(Rc::new(move || {
let Some(now) = h.elapsed() else { return Data::Number(0.0) };
let since = now.saturating_sub(last.replace(now));
#[allow(clippy::cast_precision_loss)]
Data::Number(since.as_micros() as f64)
}))
};
let getenv = {
let (h, env) = (host.clone(), env.clone());
function(move |a| {
let name = one_string(&a, "getenv")?;
h.charge_call(name.len() as u64 + 2)?;
let env = env.borrow();
let found = env.iter().find(|(n, _)| *n == name).and_then(|(_, v)| v.clone());
Ok(found.map_or(Data::Null, Data::String))
})
};
let setenv = {
let (h, env) = (host.clone(), env.clone());
function(move |a| {
let name = one_string(&a, "setenv")?;
let Some(Data::String(v)) = a.get(1) else { return Err(HostError::Type("$.setenv: give a value, as a string".into())) };
h.charge_call((name.len() + v.len()) as u64 + 4)?;
let mut env = env.borrow_mut();
let Some(slot) = env.iter_mut().find(|(n, _)| *n == name) else {
return Err(HostError::Error(format!("$.setenv: only {} can be set", ENV_NAMES.join(", "))));
};
if !env_value_ok(v) {
return Err(HostError::Range(format!("$.setenv: a value has at most {MAX_ENV_BYTES} letters, digits and _ . - @")));
}
slot.1 = Some(v.clone());
Ok(Data::Undefined)
})
};
let sleep = {
let h = host.clone();
function(move |a| {
let ms = match a.first() {
Some(Data::Number(n)) if (0.0..=MAX_SLEEP_MS).contains(n) => *n,
Some(Data::Number(_)) => return Err(HostError::Range(format!("$.sleep: give a number of milliseconds from 0 to {MAX_SLEEP_MS}"))),
_ => return Err(HostError::Type("$.sleep: give a number of milliseconds".into())),
};
h.charge_call(8)?;
h.sleep(Duration::from_secs_f64(ms / 1000.0))?;
Ok(Data::Undefined)
})
};
let about = text(format!("VectorCraft script engine {version}"));
vec![
("about".into(), HostItem::Function(function(move |_| Ok(about.clone())))),
("appEncoding".into(), app_encoding),
("bp".into(), fail("$.bp: scripts here have no debugger")),
("build".into(), value(text(version))),
("buildDate".into(), value(Data::Null)),
("colorPicker".into(), fail("$.colorPicker: scripts cannot show a dialog yet")),
("decimalPoint".into(), decimal),
("engineName".into(), value(text("VectorCraft"))),
("error".into(), HostItem::Script(ScriptItem::LastError)),
("flags".into(), flags),
("gc".into(), HostItem::Function(function(|_| Ok(Data::Undefined)))),
("getenv".into(), HostItem::Function(getenv)),
("global".into(), HostItem::Script(ScriptItem::Global)),
("hiresTimer".into(), hires_timer),
("level".into(), value(Data::Number(0.0))),
("line".into(), HostItem::Script(ScriptItem::Line)),
("locale".into(), locale),
("localize".into(), localize),
("memCache".into(), mem_cache),
("os".into(), value(text(os()))),
("screens".into(), value(Data::Array(Vec::new()))),
("setenv".into(), HostItem::Function(setenv)),
("sleep".into(), HostItem::Function(sleep)),
("stack".into(), HostItem::Script(ScriptItem::Stack)),
("strict".into(), value(Data::Bool(info.strict))),
("version".into(), value(text(version))),
]
}
const BAD_LOCALE: &str = "$.locale: give a locale such as \"en\" or \"de_DE\", or null for the interface's";
+129 -22
View File
@@ -31,7 +31,8 @@
//! 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`.
@@ -40,6 +41,7 @@
pub mod console;
pub mod dialogs;
pub mod dollar;
pub mod include;
pub use dialogs::{DialogAnswer, DialogKind, ScriptDialog};
@@ -97,7 +99,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;
@@ -156,6 +158,8 @@ pub(crate) struct Request {
pub source: String,
pub args: Map<String, Value>,
pub fuel: u64,
/// The locale the run starts with (`$.locale`).
pub locale: String,
/// The answers to the dialogs of a run with nobody to ask, in order.
pub answers: Vec<DialogAnswer>,
}
@@ -169,21 +173,27 @@ thread_local! {
static NEXT_JOB: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
/// A session's hold on its running job: while one exists the lease is held. Dropping it ends the job.
/// It keeps the run's number and its name as console lines give it, so the run's console entry can
/// end when the job is gone.
#[derive(Debug)]
pub struct JobKey(u64);
pub struct JobKey {
id: u64,
run: u64,
name: Arc<str>,
}
impl Drop for JobKey {
fn drop(&mut self) {
let _ = JOBS.try_with(|jobs| {
if let Ok(mut jobs) = jobs.try_borrow_mut() {
jobs.remove(&self.0);
jobs.remove(&self.id);
}
});
}
}
fn with_job<T>(key: &JobKey, f: impl FnOnce(&mut Job) -> T) -> Option<T> {
JOBS.with(|jobs| jobs.borrow_mut().get_mut(&key.0).map(f))
JOBS.with(|jobs| jobs.borrow_mut().get_mut(&key.id).map(f))
}
/// The state a script changes that a rollback puts back.
@@ -504,6 +514,12 @@ struct Host {
/// The file, line and column of each error `$.evalFile` raised for a file it could not run, in
/// order: the error's mark ([`HostError::Marked`]) is its index.
eval_failures: RefCell<Vec<(Arc<str>, u32, u32)>>,
/// When the run started, where the platform has a clock.
started: Option<std::time::Instant>,
/// The time since [`Host::started`] at which a `$.sleep` ends.
wake_at: Cell<Option<std::time::Duration>>,
/// Set while a host function runs where the run can stop at the call ([`Realm::wait_flag`]).
can_wait: Rc<Cell<bool>>,
/// What the last `#strict` line of the file `$.evalFile` last gave set (`None` without one).
eval_strict: Cell<Option<bool>>,
}
@@ -586,6 +602,51 @@ impl Host {
pending.end_line(|line, cut| self.line(console::Entry::Output(line), cut));
}
}
/// The time since the run started; none without a clock.
fn elapsed(&self) -> Option<std::time::Duration> {
#[cfg(test)]
if TEST_NO_CLOCK.with(Cell::get) {
return None;
}
#[cfg(test)]
if let Some(d) = TEST_ELAPSED.with(Cell::get) {
return Some(d);
}
self.started.map(|s| s.elapsed())
}
/// `$.sleep`: ends the slice, and the run does not go on until `d` has passed. Without a clock
/// it does not wait. A getter, a conversion, a built-in's callback or a file `$.evalFile` runs
/// cannot stop at the call, so a wait there is an error, with or without a clock.
fn sleep(&self, d: std::time::Duration) -> std::result::Result<(), HostError> {
if d.is_zero() {
return Ok(());
}
if !self.can_wait.get() {
return Err(HostError::Error(
"$.sleep: a run cannot wait inside a callback of a built-in function, a getter, a conversion or a file $.evalFile runs; call it from the script's own code"
.into(),
));
}
if let Some(now) = self.elapsed() {
self.wake_at.set(Some(now.saturating_add(d)));
self.pause.set(true);
}
Ok(())
}
/// The time left of a `$.sleep`, or none when the run is not asleep.
fn asleep(&self) -> Option<std::time::Duration> {
let wake = self.wake_at.get()?;
let left = self.elapsed().map_or(std::time::Duration::ZERO, |now| wake.saturating_sub(now));
if left.is_zero() {
self.wake_at.set(None);
return None;
}
Some(left)
}
fn over(&self, limit: &str) -> HostError {
HostError::Abort(limit.to_string())
}
@@ -953,8 +1014,9 @@ fn each_line(text: &str, mut done: impl FnMut(String, bool)) {
/// The `$` object: `$.write(...values)` writes the values to the Script Console as `String(value)`
/// gives them, one after another, and `$.writeln(...values)` does the same and ends the line.
/// `$.evalFile(path[, timeout])` runs a file and gives its last value, `$.fileName` is the file
/// being run and `$.includePath` the `#includepath` list.
fn dollar_object(host: &Rc<Host>) -> Vec<(String, HostItem)> {
/// being run and `$.includePath` the `#includepath` list. Its other members are
/// [`dollar::members`].
fn dollar_object(host: &Rc<Host>, info: dollar::RunInfo, localization: &Rc<vectorcraft_js::Localization>) -> Vec<(String, HostItem)> {
let writer = |end: bool| {
let h = host.clone();
HostItem::TextFunction(function(move |a| {
@@ -983,13 +1045,15 @@ fn dollar_object(host: &Rc<Host>) -> Vec<(String, HostItem)> {
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
@@ -1197,6 +1261,15 @@ impl Session {
!self.script_stop.is_set() && self.script_key.as_ref().and_then(|k| with_job(k, |j| j.host.waiting.get())).unwrap_or(false)
}
/// The time left of the running script's `$.sleep`, or none when it is not asleep. A run asked
/// to stop is not asleep: the next [`Session::script_pump`] ends it.
pub fn script_sleep_left(&self) -> Option<std::time::Duration> {
if self.script_stop.is_set() {
return None;
}
self.script_key.as_ref().and_then(|k| with_job(k, |j| j.host.asleep())).flatten()
}
/// The dialog (`alert`, `confirm` or `prompt`) the running script waits at for the person's
/// answer. A run asked to stop no longer waits: the next [`Session::script_pump`] ends it.
pub fn script_dialog(&self) -> Option<ScriptDialog> {
@@ -1246,12 +1319,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);
@@ -1261,6 +1330,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<()> {
@@ -1294,6 +1376,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());
@@ -1327,20 +1410,26 @@ impl Session {
script_map: RefCell::default(),
eval_maps: 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(|()| files::install(&mut realm, &host))
.and_then(|()| realm.start(&expanded.source))
@@ -1355,10 +1444,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)
}
@@ -1366,14 +1456,15 @@ impl Session {
/// run has ended: its report, or the error that ended it, with the documents put back.
pub fn script_pump(&mut self) -> Option<Result<Value>> {
let key = self.script_key.take()?;
let Some(mut job) = JOBS.with(|jobs| jobs.borrow_mut().remove(&key.0)) else {
let Some(mut job) = JOBS.with(|jobs| jobs.borrow_mut().remove(&key.id)) else {
self.script_gone(key, console::Ending::Internal);
return Some(Err(EngineError::Other(JOB_GONE.to_string())));
};
if let Some(e) = job.host.dialogs.take_failure() {
return Some(self.script_failed(job, &e));
}
if (job.host.waiting.get() || job.host.dialogs.is_waiting()) && !self.script_stop.is_set() {
JOBS.with(|jobs| jobs.borrow_mut().insert(key.0, job));
if (job.host.waiting.get() || job.host.asleep().is_some() || job.host.dialogs.is_waiting()) && !self.script_stop.is_set() {
JOBS.with(|jobs| jobs.borrow_mut().insert(key.id, job));
self.script_key = Some(key);
return None;
}
@@ -1395,7 +1486,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
}
@@ -1514,6 +1605,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)));
}
}
}
}
@@ -1666,6 +1762,13 @@ fn run_slice(s: &mut Session, job: &mut Job) -> std::result::Result<std::result:
r
}
/// Tests: removes the session's running job while the session keeps its hold on it. → whether a
/// job was removed.
#[cfg(test)]
pub(crate) fn forget_job(s: &Session) -> bool {
s.script_key.as_ref().is_some_and(|k| JOBS.with(|jobs| jobs.borrow_mut().remove(&k.id)).is_some())
}
/// The objects a run may add before it is asked to continue or stop.
fn growth_objects() -> u64 {
#[cfg(test)]
@@ -1694,4 +1797,8 @@ thread_local! {
pub(crate) static TEST_GROWTH: Cell<Option<u64>> = const { Cell::new(None) };
/// Tests: the `JSINCLUDE` folders in place of the environment's.
pub(crate) static TEST_JSINCLUDE: RefCell<Option<Vec<std::path::PathBuf>>> = const { RefCell::new(None) };
/// Tests: the time since a run started, in place of its clock's.
pub(crate) static TEST_ELAPSED: Cell<Option<std::time::Duration>> = const { Cell::new(None) };
/// Tests: runs have no clock, as in a wasm build.
pub(crate) static TEST_NO_CLOCK: Cell<bool> = const { Cell::new(false) };
}
+1
View File
@@ -152,6 +152,7 @@ mod saveoptions;
mod scalestrokes;
mod script_console;
mod script_dialogs;
mod script_dollar;
mod script_folder;
mod script_include;
mod script_io;
+16
View File
@@ -1481,6 +1481,22 @@ fn rasterize_refuses_an_image_longer_than_the_renderer_draws() {
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]
+49
View File
@@ -173,6 +173,55 @@ fn a_stopped_run_reports_its_diagnostics_and_its_unended_line() {
assert_eq!(texts(&mut s, "end"), ["ended: stopped"]);
}
/// A report line counts one host call in the singular, and gives a run without a clock as "time
/// unavailable".
#[test]
fn a_report_line_counts_one_host_call_in_the_singular_and_says_when_time_is_unavailable() {
use crate::script::console::{Entry, Slow};
let report = |ms: Option<u64>, host_calls: u64| Entry::Report { ms, host_calls, slowest: vec![] }.text();
assert_eq!(report(Some(3), 0), "3 ms, 0 host calls");
assert_eq!(report(Some(3), 1), "3 ms, 1 host call");
assert_eq!(report(Some(0), 2), "0 ms, 2 host calls");
assert_eq!(report(None, 0), "time unavailable, 0 host calls");
assert_eq!(report(None, 1), "time unavailable, 1 host call");
assert_eq!(report(None, 2), "time unavailable, 2 host calls");
let slowest = vec![Slow { id: "a.b".into(), calls: 1, total_ms: 4, max_ms: 4 }];
assert_eq!(Entry::Report { ms: None, host_calls: 1, slowest }.text(), "time unavailable, 1 host call; slowest: a.b ×1 4 ms");
}
/// A run whose job is gone still ends its console entry, once, with its own number and name and no
/// report line: as stopped when it is abandoned, as an internal fault when a slice finds it gone.
/// The session can start the next run.
#[test]
fn a_run_whose_job_is_gone_still_ends_its_console_entry() {
for abandon in [true, false] {
let mut s = Session::new();
s.script_frames = true;
run(&mut s, "$.writeln('one'); while (true) {}").unwrap();
assert!(s.script_pump().is_none());
assert!(script::forget_job(&s));
if abandon {
assert!(s.script_abandon());
} else {
let e = s.script_pump().unwrap().unwrap_err();
assert_eq!(e.to_string(), "the script's run is no longer available");
}
assert!(!s.script_running());
let all = lines(&mut s);
let kinds: Vec<&str> = all.iter().map(|(_, k, _)| k.as_str()).collect();
assert_eq!(kinds, ["start", "output", "end"], "{all:?}");
assert_eq!(all[2].2, if abandon { "ended: stopped" } else { "ended: internal" });
let c = s.execute("script.console", &json!({})).unwrap();
assert_eq!((c["lines"][2]["run"].as_u64(), c["lines"][2]["name"].as_str()), (Some(1), Some("t.js")));
assert!(!s.script_abandon() && s.script_pump().is_none(), "nothing is left to end");
assert_eq!(lines(&mut s).len(), 3, "the entry ends once");
s.script_frames = false;
run(&mut s, "$.writeln('two')").unwrap();
assert_eq!(texts(&mut s, "end").last().map(String::as_str), Some("ended: finished"), "the next run starts");
assert_eq!(lines(&mut s).last().map(|l| l.0), Some(2));
}
}
#[test]
fn runs_are_numbered_a_busy_run_adds_nothing_and_clear_keeps_the_report() {
let mut s = Session::new();
+247
View File
@@ -0,0 +1,247 @@
//! The `$` object's members besides `$.write` and `$.writeln`, the global `localize` and
//! `toSource`, and what of them one run keeps to itself.
use std::time::Duration;
use serde_json::{Value, json};
use crate::script::{TEST_ELAPSED, TEST_NO_CLOCK};
use crate::*;
/// The values `source` gives `vc.log`, in a run of its text.
fn logged(s: &mut Session, source: &str) -> Value {
s.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap_or_else(|e| panic!("{source}: {e}"))["log"].clone()
}
/// The one value `expr` gives, logged.
fn value(s: &mut Session, expr: &str) -> Value {
logged(s, &format!("vc.log({expr});"))[0].clone()
}
/// The name of the error `statement` throws and its message, as `Name: message`.
fn thrown(s: &mut Session, statement: &str) -> String {
value(s, &format!("(function () {{ try {{ {statement}; return 'no error'; }} catch (e) {{ return e.name + ': ' + e.message; }} }})()"))
.as_str()
.unwrap_or_default()
.to_string()
}
#[test]
fn each_member_has_its_value() {
let mut s = Session::new();
let version = env!("CARGO_PKG_VERSION");
let os = if cfg!(target_arch = "wasm32") {
"Web"
} else if cfg!(windows) {
"Windows"
} else if cfg!(target_os = "macos") {
"macOS"
} else if cfg!(target_os = "linux") {
"Linux"
} else if cfg!(target_os = "freebsd") {
"FreeBSD"
} else {
"Unix"
};
for (expr, want) in [
("$.about()", json!(format!("VectorCraft script engine {version}"))),
("$.appEncoding", json!("UTF-8")),
("$.build", json!(version)),
("$.buildDate", json!(null)),
("$.decimalPoint", json!(".")),
("$.engineName", json!("VectorCraft")),
("$.error", json!(null)),
("$.flags", json!(0)),
("$.gc() === undefined", json!(true)),
("$.global === this", json!(true)),
("typeof $.hiresTimer === 'number' && $.hiresTimer >= 0", json!(true)),
("$.level", json!(0)),
("\n$.line", json!(2)),
("$.locale", json!("en")),
("$.localize", json!(false)),
("$.memCache", json!(0)),
("$.os", json!(os)),
("$.screens.length", json!(0)),
("$.stack", json!("[global]:1\n")),
("$.strict", json!(false)),
("$.version", json!(version)),
("typeof $.getenv + typeof $.setenv + typeof $.sleep", json!("functionfunctionfunction")),
("vc.api.has('dollar')", json!(true)),
] {
assert_eq!(value(&mut s, expr), want, "{expr}");
}
assert_eq!(thrown(&mut s, "$.bp()"), "Error: $.bp: scripts here have no debugger");
assert_eq!(thrown(&mut s, "$.colorPicker(0)"), "Error: $.colorPicker: scripts cannot show a dialog yet");
}
#[test]
fn read_only_members_keep_their_values_and_settable_ones_check_what_they_are_given() {
let mut s = Session::new();
assert_eq!(value(&mut s, "($.build = 'x', $.level = 3, $.os = 'y', [$.build === 'x', $.level, $.os === 'y'])"), json!([false, 0, false]));
assert!(thrown(&mut s, "(function () { 'use strict'; $.version = '9'; })()").starts_with("TypeError"));
assert!(thrown(&mut s, "(function () { 'use strict'; $.error = null; })()").starts_with("TypeError"));
assert_eq!(value(&mut s, "($.appEncoding = 'utf-8', $.appEncoding)"), json!("UTF-8"));
assert!(thrown(&mut s, "$.appEncoding = 'ISO-8859-1'").starts_with("RangeError"));
assert!(thrown(&mut s, "$.appEncoding = 8").starts_with("TypeError"));
assert!(thrown(&mut s, "$.memCache = 1024").starts_with("Error"));
assert_eq!(value(&mut s, "($.flags = 512, [$.flags, $.localize])"), json!([512, true]));
assert_eq!(value(&mut s, "($.flags = 512, $.flags = 0, [$.flags, $.localize])"), json!([0, false]));
for bad in ["$.flags = 2", "$.flags = 513", "$.flags = -512"] {
assert!(thrown(&mut s, bad).starts_with("RangeError"), "{bad}");
}
assert!(thrown(&mut s, "$.flags = '512'").starts_with("TypeError"));
assert!(thrown(&mut s, "$.localize = 1").starts_with("TypeError"));
}
#[test]
fn the_locale_starts_as_the_interfaces_and_a_change_lasts_one_run() {
let mut s = Session::new();
for (ui, want) in [("de", "de"), ("pt-br", "pt_BR"), ("zh-hant", "zh_TW"), ("zh-hans", "zh_CN"), ("auto", "en"), ("x", "en")] {
s.ui_language = Some(ui.into());
assert_eq!(value(&mut s, "$.locale"), json!(want), "{ui}");
}
s.ui_language = None;
s.prefs.interface_language = "fr".into();
assert_eq!(value(&mut s, "$.locale"), json!("fr"), "without a language from the app, the preference");
s.ui_language = Some("de".into());
let lookup = "localize({en: 'e', de: 'd', fr: 'f', fr_CA: 'c'})";
assert_eq!(value(&mut s, &format!("[{lookup}, $.decimalPoint]")), json!(["d", ","]));
let changed = format!(
"$.locale = 'FR-ca'; var a = [$.locale, {lookup}]; $.locale = 'fr'; a.push({lookup}); $.locale = null; a.push($.locale, {lookup}); a"
);
assert_eq!(value(&mut s, &format!("(function () {{ {changed}; return a; }})()")), json!(["fr_CA", "c", "f", "de", "d"]));
assert_eq!(value(&mut s, "($.locale = '', $.locale)"), json!("de"), "an empty locale is the interface's");
assert_eq!(value(&mut s, "($.locale = 'en_US', $.locale)"), json!("en_US"));
assert_eq!(value(&mut s, "$.locale"), json!("de"), "the next run starts with the interface's again");
for bad in ["$.locale = 'german'", "$.locale = 'de_DE_x'", "$.locale = 'd'"] {
assert!(thrown(&mut s, bad).starts_with("RangeError"), "{bad}");
}
assert!(thrown(&mut s, "$.locale = 5").starts_with("TypeError"));
}
#[test]
fn localized_to_string_is_on_only_while_the_run_turns_it_on() {
let mut s = Session::new();
s.ui_language = Some("de".into());
let text = "String({en: 'Open', de: 'Öffnen'})";
assert_eq!(value(&mut s, text), json!("[object Object]"));
assert_eq!(value(&mut s, &format!("($.localize = true, {text})")), json!("Öffnen"));
assert_eq!(value(&mut s, text), json!("[object Object]"), "the next run starts with it off");
assert_eq!(value(&mut s, "localize('%1 of %2', 3, 7)"), json!("3 of 7"), "localize works with it off");
}
#[test]
fn to_source_writes_plain_data() {
let mut s = Session::new();
assert_eq!(value(&mut s, "toSource({a: [1, 'x'], b: null})"), json!(r#"({"a":[1, "x"], "b":null})"#));
assert_eq!(value(&mut s, "[true, 2].toSource()"), json!("[true, 2]"));
assert!(thrown(&mut s, "var o = {}; o.o = o; toSource(o)").starts_with("TypeError"));
assert!(thrown(&mut s, "toSource(vc)").starts_with("TypeError"), "a host object is not plain data");
}
#[test]
fn getenv_reads_only_its_names_and_setenv_changes_only_the_run() {
let mut s = Session::new();
for name in ["HOME", "PATH", "USERPROFILE", "TEMP", "lang", ""] {
assert_eq!(value(&mut s, &format!("$.getenv('{name}')")), json!(null), "{name}");
}
let process = std::env::var("OS").ok();
let before = value(&mut s, "$.getenv('OS')");
if let Some(os) = process.as_deref().filter(|v| v.len() <= 64 && v.bytes().all(|b| b.is_ascii_alphanumeric() || b"_.-@".contains(&b))) {
assert_eq!(before, json!(os));
}
assert_eq!(value(&mut s, "($.setenv('OS', 'Test-1'), $.getenv('OS'))"), json!("Test-1"));
assert_eq!(std::env::var("OS").ok(), process, "the process's environment stays as it was");
assert_eq!(value(&mut s, "$.getenv('OS')"), before, "the next run reads the process's value again");
assert!(thrown(&mut s, "$.setenv('HOME', 'x')").starts_with("Error"));
assert!(thrown(&mut s, "$.setenv('LANG', 'a b')").starts_with("RangeError"));
assert!(thrown(&mut s, &format!("$.setenv('LANG', '{}')", "a".repeat(65))).starts_with("RangeError"));
assert!(thrown(&mut s, "$.setenv('LANG')").starts_with("TypeError"));
assert!(thrown(&mut s, "$.getenv(1)").starts_with("TypeError"));
}
#[test]
fn error_line_and_stack_follow_the_running_code() {
let mut s = Session::new();
let source = "try { null.x; } catch (e) {}\nfunction where() {\n return [$.line, $.stack, $.error instanceof TypeError];\n}\nvc.log(where());";
assert_eq!(logged(&mut s, source)[0], json!([3, "[global]:5\nwhere:3\n", true]));
assert_eq!(value(&mut s, "$.error"), json!(null), "each run starts without one");
}
#[test]
fn sleep_waits_between_slices_until_its_time_has_passed() {
let mut s = Session::new();
s.script_frames = true;
TEST_ELAPSED.set(Some(Duration::ZERO));
s.execute("script.run", &json!({"source": "vc.log(1); $.sleep(250); vc.log(2);", "name": "t.js"})).unwrap();
let logs =
|s: &mut Session| s.execute("script.console", &json!({})).unwrap()["lines"].as_array().unwrap().iter().filter(|l| l["kind"] == "log").count();
assert!(s.script_pump().is_none());
assert_eq!(logs(&mut s), 1);
TEST_ELAPSED.set(Some(Duration::from_millis(249)));
assert!(s.script_pump().is_none());
assert!(s.script_pump().is_none());
assert_eq!(logs(&mut s), 1, "the run waits");
assert_eq!(s.script_sleep_left(), Some(Duration::from_millis(1)), "the app can wait this long before the next frame");
TEST_ELAPSED.set(Some(Duration::from_millis(250)));
let r = s.script_pump().unwrap().unwrap();
assert_eq!(r["log"], json!([1, 2]));
TEST_ELAPSED.set(None);
}
#[test]
fn a_sleeping_run_stops_when_asked() {
let mut s = Session::new();
s.script_frames = true;
TEST_ELAPSED.set(Some(Duration::ZERO));
s.execute("script.run", &json!({"source": "$.sleep(1000); vc.log(2);", "name": "t.js"})).unwrap();
assert!(s.script_pump().is_none());
assert_eq!(s.script_sleep_left(), Some(Duration::from_secs(1)));
s.execute("script.stop", &json!({})).unwrap();
assert_eq!(s.script_sleep_left(), None, "a run asked to stop does not wait");
assert!(s.script_pump().unwrap().is_err());
assert_eq!(s.script_report().unwrap()["kind"], "stopped");
TEST_ELAPSED.set(None);
}
#[test]
fn sleep_takes_milliseconds_from_0_to_1000() {
let mut s = Session::new();
assert_eq!(value(&mut s, "($.sleep(0), $.sleep(1), 'woke')"), json!("woke"));
for bad in ["$.sleep(-1)", "$.sleep(1001)"] {
assert!(thrown(&mut s, bad).starts_with("RangeError"), "{bad}");
}
for bad in ["$.sleep()", "$.sleep('5')", "$.sleep(NaN)", "$.sleep(Infinity)"] {
assert!(thrown(&mut s, bad).starts_with("TypeError"), "{bad}");
}
}
#[test]
fn sleep_throws_where_the_run_cannot_wait_instead_of_running_on() {
let mut s = Session::new();
s.script_frames = true;
TEST_ELAPSED.set(Some(Duration::ZERO));
let source = "var o = {}; Object.defineProperty(o, 'x', { get: function () { $.sleep(250); vc.log('after'); return 1; } });
try { o.x; } catch (e) { vc.log(e.name + ': ' + e.message); }
try { [1].map(function () { $.sleep(250); vc.log('after'); }); } catch (e) { vc.log(e.name + ': ' + e.message); }
[1].map(function () { $.sleep(0); });
vc.log('done');";
s.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap();
let r = s.script_pump().expect("the run does not wait").unwrap();
TEST_ELAPSED.set(None);
let message = "Error: $.sleep: a run cannot wait inside a callback of a built-in function, a getter, a conversion or a file $.evalFile runs; call it from the script's own code";
assert_eq!(r["log"], json!([message, message, "done"]));
}
#[test]
fn sleep_throws_where_the_run_cannot_wait_without_a_clock_too() {
let mut s = Session::new();
TEST_NO_CLOCK.set(true);
let source = "var o = {}; Object.defineProperty(o, 'x', { get: function () { $.sleep(250); return 1; } });
try { o.x; vc.log('no error'); } catch (e) { vc.log(e.name); }
$.sleep(250);
vc.log('done');";
let log = logged(&mut s, source);
TEST_NO_CLOCK.set(false);
assert_eq!(log, json!(["Error", "done"]), "the script's own code does not wait without a clock");
}
+80
View File
@@ -284,6 +284,86 @@ fn pasted_art_with_a_filter_at_the_size_limit_still_draws_when_renamed() {
}
}
/// A pasted filter is not renamed to a name an effect in the document already gives, even one whose
/// filter was deleted, so defining a filter of that name later changes only that effect.
#[test]
fn pasted_art_takes_no_name_the_documents_effects_use() {
let mut s = session();
let a = rect(&mut s, 10.0);
run(&mut s, "svgFilter.define", json!({"xml": r#"<filter id="Shift 2"><feOffset dx="1" dy="1"/></filter>"#}));
run(&mut s, "svgFilter.apply", json!({"name": "Shift 2", "ids": [a.0]}));
run(&mut s, "svgFilter.delete", json!({"name": "Shift 2"}));
run(&mut s, "svgFilter.define", json!({"xml": SHIFT}));
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs>{SHIFT_MORE}</defs><rect width="30" height="30" filter="url(#Shift)"/></svg>"#
);
run(&mut s, "clipboard.importSvg", json!({"svg": svg}));
run(&mut s, "edit.pasteInPlace", json!({}));
let names: Vec<String> = filter_effects(&s).into_iter().map(|(n, _)| n).collect();
assert_eq!(names, ["Shift 2", "Shift 3"]);
}
/// A pasted filter is not renamed to a name an effect in a brush's art gives either.
#[test]
fn pasted_art_takes_no_name_the_documents_brushes_use() {
let mut s = session();
let a = rect(&mut s, 10.0);
run(&mut s, "svgFilter.define", json!({"xml": r#"<filter id="Shift 2"><feOffset dx="1" dy="1"/></filter>"#}));
run(&mut s, "svgFilter.apply", json!({"name": "Shift 2", "ids": [a.0]}));
run(&mut s, "select.set", json!({"ids": [a.0]}));
run(&mut s, "brush.new", json!({"type": "art", "name": "Fx"}));
run(&mut s, "edit.clear", json!({"ids": [a.0]}));
run(&mut s, "svgFilter.delete", json!({"name": "Shift 2"}));
run(&mut s, "svgFilter.define", json!({"xml": SHIFT}));
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs>{SHIFT_MORE}</defs><rect width="30" height="30" filter="url(#Shift)"/></svg>"#
);
run(&mut s, "clipboard.importSvg", json!({"svg": svg}));
run(&mut s, "edit.pasteInPlace", json!({}));
let names: Vec<String> = filter_effects(&s).into_iter().map(|(n, _)| n).collect();
assert_eq!(names, ["Shift 3"]);
}
/// A pasted filter at the size limit with a one-letter name, when every free name it could take is
/// in use, is refused rather than pasted under the name of the document's filter.
#[test]
fn a_paste_with_no_free_name_for_its_filter_is_refused() {
let mut s = session();
for n in ('a'..='z').chain('0'..='9') {
run(&mut s, "svgFilter.define", json!({"xml": format!(r#"<filter id="{n}"><feOffset dx="1" dy="1"/></filter>"#)}));
}
run(&mut s, "svgFilter.define", json!({"xml": r#"<filter id="S"><feOffset dx="3" dy="3"/></filter>"#}));
let (open, close) = (r#"<filter id="S"><feOffset dx="9" dy="9" result=""#, r#""/></filter>"#);
let big = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len()));
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs>{big}</defs><rect width="30" height="30" filter="url(#S)"/></svg>"#
);
run(&mut s, "clipboard.importSvg", json!({"svg": svg}));
let before = steps(&s);
assert!(err(&mut s, "edit.pasteInPlace", json!({})).contains("no free name"));
assert!(filter_effects(&s).is_empty(), "nothing pasted");
assert_eq!(steps(&s), before);
}
/// A pasted filter whose name holds a tab, a line feed or a carriage return keeps those characters
/// in its id under its new name.
#[test]
fn a_renamed_paste_keeps_whitespace_characters_in_its_id() {
for c in ["&#9;", "&#10;", "&#13;"] {
let mut s = session();
run(&mut s, "svgFilter.define", json!({"xml": format!(r#"<filter id="S{c}t"><feOffset dx="3" dy="3"/></filter>"#)}));
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs><filter id="S{c}t"><feOffset dx="9" dy="9"/></filter></defs><rect width="30" height="30" filter="url(#S{c}t)"/></svg>"#
);
run(&mut s, "clipboard.importSvg", json!({"svg": svg}));
run(&mut s, "edit.pasteInPlace", json!({}));
let pasted = filter_effects(&s);
assert_eq!(pasted.len(), 1);
assert_ne!(pasted[0].0, names(&mut s)[0], "renamed");
assert_eq!(vectorcraft_render::effects::svg_filter::check(&pasted[0].1), Ok(pasted[0].0.clone()), "{c}: its id is its name");
}
}
/// Art in a symbol's definition that uses a filter follows its edits too.
#[test]
fn a_symbols_art_follows_a_filter_edit() {
+45
View File
@@ -525,6 +525,51 @@ fn many_objects_of_one_name_are_numbered_in_turn() {
assert!(text.contains("(Dot_00000000000000019999_)"));
}
/// A document with one top-level layer for each of the 27 layer colour presets, named after its
/// position.
fn every_layer_preset() -> Document {
let mut doc = Document::new(100.0, 100.0);
doc.layers.clear();
for i in 0..vectorcraft_doc::LAYER_COLORS.len() {
let id = doc.alloc_id();
doc.layers.push(Arc::new(Node::layer(id, &format!("P{i}"), LayerColor::Preset(u8::try_from(i).unwrap()))));
}
doc
}
/// #6502: Yellow, Magenta and Cyan (presets 4, 5 and 6) are written as the file's indexes 6, 4
/// and 5, with their own RGB beside them; every other preset keeps its position.
#[test]
fn layer_presets_are_written_with_the_files_colour_index() {
let text = program(&every_layer_preset());
for (i, (_, [r, g, b])) in vectorcraft_doc::LAYER_COLORS.iter().enumerate() {
let index = match i {
4 => 6,
5 => 4,
6 => 5,
i => i,
};
let want = format!(" {index} {r} {g} {b} 0 50 0 Lb\n(P{i}) Ln\n");
assert!(text.contains(&want), "missing {want:?}");
}
}
/// #6502: every preset comes back as itself after a save and an open.
#[test]
fn layer_presets_survive_a_round_trip() {
let doc = read_back(&every_layer_preset());
let colors: Vec<LayerColor> = doc
.layers
.iter()
.map(|l| match l.kind {
NodeKind::Layer { color, .. } => color,
_ => panic!("{:?}", l.kind),
})
.collect();
let want: Vec<LayerColor> = (0..27u8).map(LayerColor::Preset).collect();
assert_eq!(colors, want);
}
#[test]
fn layers_have_fourteen_operands() {
let text = program(&sample());
+2 -9
View File
@@ -7,7 +7,7 @@ use std::sync::Arc;
use vectorcraft_color::cms::Cms;
use vectorcraft_color::swatch::{REGISTRATION, Swatch};
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Knockout, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, OpacityMask, StrokeLayer};
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Knockout, LayerColor, Node, NodeId, NodeKind, OpacityMask, StrokeLayer};
use vectorcraft_effects::stroke;
use vectorcraft_geom::{Affine, FillRule, PathData, Shape, SubPath};
use vectorcraft_pathops::BoolOp;
@@ -449,14 +449,7 @@ impl<'a> Writer<'a> {
/// (`Xy`, `0 0 Xd`, `7 () XW`) when it has any, `LB`, `%AI5_EndLayer--`.
fn write_layer(&mut self, n: &Node, after: &[Arc<Node>]) {
let NodeKind::Layer { color, template, printable, children, clip, preview, dim_images } = &n.kind else { return };
let [r, g, b] = match color {
LayerColor::Preset(i) => LAYER_COLORS.get(usize::from(*i)).map_or([79, 128, 255], |c| c.1),
LayerColor::Custom(rgb) => *rgb,
};
let index = match color {
LayerColor::Preset(i) if usize::from(*i) < LAYER_COLORS.len() => i64::from(*i),
_ => -1,
};
let (index, [r, g, b]) = ai_ops::layer_color(*color);
if *template {
self.warn(TEMPLATES);
}
+38 -1
View File
@@ -6,7 +6,7 @@
use std::fmt::Write;
use vectorcraft_color::{Color, Gradient};
use vectorcraft_doc::{LineCap, LineJoin, StrokeLayer};
use vectorcraft_doc::{LAYER_COLORS, LayerColor, LineCap, LineJoin, StrokeLayer};
use vectorcraft_geom::{Affine, Anchor, AnchorKind, PathData, Point, SubPath};
use crate::ps;
@@ -62,6 +62,43 @@ pub(crate) fn guide(s: &mut String, xf: Affine, path: &PathData, editing: bool)
}
}
/// The colour index an `.ai` layer's `Lb` gives preset `preset` (its position in
/// [`LAYER_COLORS`]). The file numbers Magenta, Cyan and Yellow 4, 5 and 6, where
/// [`LAYER_COLORS`] has Yellow, Magenta and Cyan; every other preset has its own position (#6502,
/// from the index and RGB pairs of the `Lb` records in files users sent). A reader takes the index
/// and ignores the RGB beside it, so the index decides the colour.
pub(crate) fn ai_layer_index(preset: u8) -> u8 {
match preset {
4 => 6,
5 => 4,
6 => 5,
i => i,
}
}
/// The preset (position in [`LAYER_COLORS`]) of the colour index `index` in an `.ai` layer's
/// `Lb`: the inverse of [`ai_layer_index`].
pub(crate) fn preset_of_ai_layer_index(index: u8) -> u8 {
match index {
6 => 4,
4 => 5,
5 => 6,
i => i,
}
}
/// The colour index and RGB an `.ai` layer's `Lb` writes for `color`: a preset's index
/// ([`ai_layer_index`]) with its RGB, or `-1` with a custom colour's RGB.
pub(crate) fn layer_color(color: LayerColor) -> (i64, [u8; 3]) {
match color {
LayerColor::Preset(i) => match LAYER_COLORS.get(usize::from(i)) {
Some((_, rgb)) => (i64::from(ai_layer_index(i)), *rgb),
None => (-1, [79, 128, 255]),
},
LayerColor::Custom(rgb) => (-1, rgb),
}
}
pub(crate) fn unit(v: f32) -> f32 {
if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }
}
+2 -1
View File
@@ -471,8 +471,9 @@ impl<'a> Reader<'a> {
a.dim = flag(4, false).then_some(dim_pct.round() as u8);
let index = n.get(at).copied().unwrap_or(0.0);
let rgb = |i: usize| n.get(at + i).map_or(0, |v| v.clamp(0.0, 255.0) as u8);
// The file numbers some presets differently from `LAYER_COLORS` (#6502).
a.color = Some(if (0.0..vectorcraft_doc::LAYER_COLORS.len() as f64).contains(&index) && index.fract() == 0.0 {
LayerColor::Preset(index as u8)
LayerColor::Preset(crate::ai_ops::preset_of_ai_layer_index(index as u8))
} else {
LayerColor::Custom([rgb(1), rgb(2), rgb(3)])
});
+26 -2
View File
@@ -8465,20 +8465,44 @@ fn layer_with(ops: &str) -> Node {
/// the colour index and the last one change nothing.
#[test]
fn the_template_flag_of_a_layer() {
// Index 4 is the file's Magenta (#6502).
let l = layer_with("1 1 0 0 1 0 0 4 255 79 255 1 50 0");
let NodeKind::Layer { template, printable, dim_images, color, .. } = l.kind else { panic!() };
assert!(template && !printable && l.locked);
assert_eq!((dim_images, color), (Some(50), LayerColor::Preset(4)));
assert_eq!((dim_images, color), (Some(50), LayerColor::Preset(5)));
// Index 5 is the file's Cyan (#6502).
for ops in ["1 1 1 1 1 0 1 5 1 2 3 1 30 0", "1 1 1 1 1 0 0 5 1 2 3 1 30 1", "1 1 1 1 1 0 1 5 1 2 3 1 30"] {
let l = layer_with(ops);
let NodeKind::Layer { template, printable, dim_images, color, .. } = l.kind else { panic!() };
assert!(template && printable && !l.locked, "{ops}");
assert_eq!((dim_images, color), (Some(30), LayerColor::Preset(5)), "{ops}");
assert_eq!((dim_images, color), (Some(30), LayerColor::Preset(6)), "{ops}");
}
let l = layer_with("1 1 1 1 0 0 1 0 79 128 255 0 50 0");
assert!(matches!(l.kind, NodeKind::Layer { template: false, .. }));
}
/// #6502: an `.ai` file numbers Magenta, Cyan and Yellow 4, 5 and 6 (the index and RGB pairs of
/// the `Lb` records in files users sent), so they open as those presets; the index decides, not
/// the RGB beside it, and the other presets keep their position.
#[test]
fn layer_colour_indexes_follow_the_file_format() {
let color = |ops: &str| match layer_with(ops).kind {
NodeKind::Layer { color, .. } => color,
_ => panic!(),
};
let preset = |name: &str| {
let i = vectorcraft_doc::LAYER_COLORS.iter().position(|(n, _)| *n == name).unwrap();
LayerColor::Preset(u8::try_from(i).unwrap())
};
for (index, rgb, name) in
[(3, "79 79 255", "Blue"), (4, "255 79 255", "Magenta"), (5, "79 255 255", "Cyan"), (6, "255 255 79", "Yellow"), (7, "128 128 128", "Gray")]
{
assert_eq!(color(&format!("1 1 1 1 0 0 0 {index} {rgb} 0 50 0")), preset(name), "index {index}");
}
// The RGB beside an index doesn't change the preset.
assert_eq!(color("1 1 1 1 0 0 0 4 255 255 79 0 50 0"), preset("Magenta"));
}
/// The colour index is one place earlier in eleven and twelve operands, as in ten, and the twelfth
/// is the dimming percentage; with nine it is one place earlier still.
#[test]
+2 -2
View File
@@ -65,8 +65,8 @@ use std::sync::Arc;
use vectorcraft_color::cms::Cms;
use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{
AppearanceItem, AreaOptions, CharPosition, CharStyle, CompositeFont, Document, FillLayer, ImageBlob, Justify, LAYER_COLORS, LayerColor, Node,
NodeKind, ParaDirection, ParaStyle, PathAlign, PathEffect, StrokeLayer, TextKind, TextObject,
AppearanceItem, AreaOptions, CharPosition, CharStyle, CompositeFont, Document, FillLayer, ImageBlob, Justify, Node, NodeKind, ParaDirection,
ParaStyle, PathAlign, PathEffect, StrokeLayer, TextKind, TextObject,
};
use vectorcraft_effects::stroke;
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, SubPath, Vec2};
+29
View File
@@ -940,6 +940,35 @@ fn names_outside_windows_1252_are_replaced_and_kept_apart() {
assert_eq!(inks(&read), want);
}
/// #6502: the legacy writer gives Yellow, Magenta and Cyan (presets 4, 5 and 6) the file's
/// indexes 6, 4 and 5, and they open as the same presets.
#[test]
fn layer_presets_are_written_with_the_files_colour_index() {
let mut doc = Document::new(100.0, 100.0);
doc.layers.clear();
for i in 3..8u8 {
let id = doc.alloc_id();
doc.layers.push(Arc::new(Node::layer(id, &format!("P{i}"), vectorcraft_doc::LayerColor::Preset(i))));
}
let out = write(&doc);
let text = String::from_utf8_lossy(&out.bytes).replace("\r\n", "\n");
for (i, index) in [(3, 3), (4, 6), (5, 4), (6, 5), (7, 7)] {
let [r, g, b] = vectorcraft_doc::LAYER_COLORS[i].1;
let want = format!(" 0 {index} {r} {g} {b} Lb\n(P{i}) Ln\n");
assert!(text.contains(&want), "missing {want:?} in\n{text}");
}
let read = crate::import(&out.bytes).unwrap().document;
let colors: Vec<_> = read
.layers
.iter()
.filter_map(|l| match l.kind {
NodeKind::Layer { color, .. } => Some(color),
_ => None,
})
.collect();
assert_eq!(colors, (3..8u8).map(vectorcraft_doc::LayerColor::Preset).collect::<Vec<_>>());
}
#[test]
fn losses_are_warned() {
let mut doc = sample();
+5 -19
View File
@@ -5,9 +5,9 @@ use super::encoding::{
use super::{
Affine, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule, Gradient,
GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE,
LAYER_COLORS, LAYER_NAME, LIVE, LayerColor, LegacyAiOptions, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS, Paint, ParaDirection,
ParaStyle, PathData, ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE,
TYPE_ATTRIBUTES, TextKind, TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke,
LAYER_NAME, LIVE, LegacyAiOptions, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS, Paint, ParaDirection, ParaStyle, PathData,
ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind,
TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke,
};
pub(super) struct Writer<'a> {
@@ -135,14 +135,7 @@ impl<'a> Writer<'a> {
/// A top-level layer. False when it isn't a layer.
pub(super) fn layer(&mut self, n: &Node) -> bool {
let NodeKind::Layer { color, template, printable, children, clip, preview, dim_images } = &n.kind else { return false };
let [r, g, b] = match color {
LayerColor::Preset(i) => LAYER_COLORS.get(usize::from(*i)).map_or([79, 128, 255], |c| c.1),
LayerColor::Custom(rgb) => *rgb,
};
let index = match color {
LayerColor::Preset(i) if usize::from(*i) < LAYER_COLORS.len() => i64::from(*i),
_ => -1,
};
let (index, [r, g, b]) = ai_ops::layer_color(*color);
let flag = |b: bool| u8::from(b);
let name = self.layer_name(n.name.as_deref().unwrap_or("Layer"));
// A template layer doesn't print and shows dimmed.
@@ -173,14 +166,7 @@ impl<'a> Writer<'a> {
}
let art = std::mem::take(&mut self.hidden_art);
let NodeKind::Layer { color, template, printable, .. } = &n.kind else { return false };
let [r, g, b] = match color {
LayerColor::Preset(i) => LAYER_COLORS.get(usize::from(*i)).map_or([79, 128, 255], |c| c.1),
LayerColor::Custom(rgb) => *rgb,
};
let index = match color {
LayerColor::Preset(i) if usize::from(*i) < LAYER_COLORS.len() => i64::from(*i),
_ => -1,
};
let (index, [r, g, b]) = ai_ops::layer_color(*color);
let name = format!("{} (hidden)", n.name.as_deref().unwrap_or("Layer"));
let name = self.layer_name(&name);
let _ = writeln!(
+8
View File
@@ -10,6 +10,7 @@
//! run past [`crate::Limits`].
mod array;
pub(crate) mod extensions;
mod global;
mod json;
mod math;
@@ -358,6 +359,13 @@ fn object_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResu
/// `Object.prototype.toString` (section 15.2.4.2).
fn object_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
// With `$.localize` on, a localization object gives its text for the current locale.
if interp.localization.enabled()
&& let Value::Object(id) = this
&& let Some(text) = extensions::localized_text(interp, id)?
{
return Ok(text);
}
let class = match &this {
Value::Undefined => "Undefined",
Value::Null => "Null",
+400
View File
@@ -0,0 +1,400 @@
//! The script extensions a host adds with [`crate::Realm::add_script_extensions`]: the global
//! `localize`, the global `toSource` and `Object.prototype.toSource`, and the getters behind
//! [`crate::ScriptItem`]. `localize` and `Object.prototype.toString` read the locale of
//! [`Localization`].
//!
//! `toSource` writes primitives, arrays and plain objects as the text of a JavaScript expression
//! that makes the same value: strings in double quotes, property names quoted, properties in the
//! order the object lists them, a hole in an array as an empty element. It never runs a getter or
//! a script's own method: a getter, a function, a date, a regular expression, an error, a boxed
//! primitive, an array with properties besides its elements, an object whose prototype is not
//! `Object.prototype`, a property that is not enumerable or any other object that is not plain
//! data throws a `TypeError`, and so does an object that contains itself. A value nested deeper, with more entries or longer text than the
//! realm's [`crate::DataLimits`] allow throws a `RangeError`. Every value written takes fuel.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use super::{Builder, arg, method};
use crate::heap::{Class, NativeFn, ObjId, Prop, key};
use crate::interp::{ErrorType, Interp, JsResult};
use crate::value::{Value, array_index, index_key, number_to_str};
use crate::{ScriptItem, bytecode::Code};
/// The locale `localize` looks a localization object up in, and whether `toString` gives an
/// object's localized text (`$.localize`). A host changes it through [`crate::Realm::localization`].
#[derive(Debug)]
pub struct Localization {
locale: RefCell<String>,
platform: Cell<Option<&'static str>>,
enabled: Cell<bool>,
}
impl Default for Localization {
fn default() -> Self {
Self { locale: RefCell::new("en".into()), platform: Cell::new(None), enabled: Cell::new(false) }
}
}
impl Localization {
/// The locale, a language and an optional region joined by `_`, such as `de_DE`.
pub fn locale(&self) -> String {
self.locale.borrow().clone()
}
pub fn set_locale(&self, locale: &str) {
*self.locale.borrow_mut() = locale.to_string();
}
/// The platform suffix the lookup tries first (`Win`, `Mac` or `Unix`), or none.
pub fn platform(&self) -> Option<&'static str> {
self.platform.get()
}
pub fn set_platform(&self, platform: Option<&'static str>) {
self.platform.set(platform);
}
/// Does `Object.prototype.toString` give an object's localized text?
pub fn enabled(&self) -> bool {
self.enabled.get()
}
pub fn set_enabled(&self, on: bool) {
self.enabled.set(on);
}
/// The property names a localization object is looked up by, in order: the locale with the
/// platform suffix, the locale, its language with the suffix, its language, and `en`.
pub fn keys(&self) -> Vec<String> {
let locale = self.locale();
let language = locale.split('_').next().unwrap_or_default().to_string();
let mut keys = Vec::with_capacity(5);
for name in [&locale, &language] {
if let Some(p) = self.platform() {
keys.push(format!("{name}_{p}"));
}
keys.push(name.clone());
}
keys.push("en".into());
let mut unique = Vec::with_capacity(keys.len());
for k in keys {
if !k.is_empty() && !unique.contains(&k) {
unique.push(k);
}
}
unique
}
}
pub(crate) fn install(interp: &mut Interp) -> JsResult<()> {
let global = interp.global;
method(interp, global, "localize", localize, 1)?;
method(interp, global, "toSource", to_source, 1)?;
let object_proto = interp.intrinsics.object_proto;
method(interp, object_proto, "toSource", to_source_method, 0)
}
/// The text of the localization object `id` for the current locale: the string value of the first
/// of [`Localization::keys`] that the object has as its own data property. No getter runs.
pub(crate) fn localized_text(interp: &mut Interp, id: ObjId) -> JsResult<Option<Value>> {
if !matches!(interp.object(id)?.class, Class::Ordinary) {
return Ok(None);
}
for k in interp.localization.keys() {
interp.use_fuel()?;
if let Some(Prop::Data { value: v @ Value::String(_), .. }) = interp.get_own_property(id, &key(&k))? {
return Ok(Some(v));
}
}
Ok(None)
}
/// `localize(object, ...values)`: the text of a localization object for the current locale, or of
/// a string, with `%1` replaced by the first value, `%2` by the second and so on. A number with no
/// value stays as it is. An object with none of the locale's names is returned as it is.
fn localize(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
let mark = interp.temp_roots.len();
interp.temp_roots.extend(args.iter().cloned());
let r = localize_rooted(interp, args);
interp.temp_roots.truncate(mark);
r
}
fn localize_rooted(interp: &mut Interp, args: &[Value]) -> JsResult<Value> {
let text = match args.first() {
Some(Value::String(s)) => s.flat(),
Some(first @ Value::Object(id)) => match localized_text(interp, *id)? {
Some(Value::String(s)) => s.flat(),
_ => return Ok(first.clone()),
},
_ => return Err(interp.type_error("localize: the first argument must be a localization object or a string")),
};
let values = args.get(1..).unwrap_or_default();
let mut converted: Vec<Option<Rc<[u16]>>> = vec![None; values.len()];
let mut out = Builder::new();
let (mut start, mut i) = (0, 0);
while let Some(&u) = text.get(i) {
if u != u16::from(b'%') {
i += 1;
continue;
}
let digits = text.get(i + 1..).unwrap_or_default().iter().take(4).take_while(|d| (0x30..=0x39).contains(*d)).count();
let n = text.get(i + 1..i + 1 + digits).unwrap_or_default().iter().fold(0usize, |n, d| n * 10 + usize::from(*d - 0x30));
let Some(slot) = n.checked_sub(1).filter(|&k| digits > 0 && k < values.len()) else {
i += 1 + digits;
continue;
};
out.push_units(interp, text.get(start..i).unwrap_or_default())?;
let units = match converted.get(slot).cloned().flatten() {
Some(u) => u,
None => {
let s = interp.to_str(&arg(values, slot))?.flat();
if let Some(c) = converted.get_mut(slot) {
*c = Some(s.clone());
}
s
}
};
out.push_units(interp, &units)?;
i += 1 + digits;
start = i;
}
out.push_units(interp, text.get(start..).unwrap_or_default())?;
Ok(out.finish(interp))
}
/// The global `toSource(value)`.
fn to_source(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
write_source(interp, arg(args, 0))
}
/// `Object.prototype.toSource()`: the source of the value it is called on.
fn to_source_method(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
write_source(interp, this)
}
fn write_source(interp: &mut Interp, v: Value) -> JsResult<Value> {
let mark = interp.temp_roots.len();
interp.temp_roots.push(v.clone());
let mut w = Source { out: Builder::new(), ancestors: Vec::new(), entries: 0 };
let r = w.value(interp, &v);
interp.temp_roots.truncate(mark);
r?;
Ok(w.out.finish(interp))
}
/// A value being written as source: the text so far, the objects it is inside and the entries it
/// has written.
struct Source {
out: Builder,
ancestors: Vec<ObjId>,
entries: usize,
}
impl Source {
fn push(&mut self, interp: &mut Interp, s: &str) -> JsResult<()> {
self.out.push_str(interp, s)?;
self.check_length(interp)
}
fn check_length(&mut self, interp: &mut Interp) -> JsResult<()> {
self.fit(interp, 0)
}
/// Checks that `more` code units can be added without passing the length limit.
fn fit(&mut self, interp: &mut Interp, more: usize) -> JsResult<()> {
if self.out.units.len().saturating_add(more) > interp.data_limits.bytes {
return Err(interp.throw(ErrorType::Range, "toSource: the source is too long"));
}
Ok(())
}
fn count(&mut self, interp: &mut Interp, n: usize) -> JsResult<()> {
self.entries = self.entries.saturating_add(n);
if self.entries > interp.data_limits.entries {
return Err(interp.throw(ErrorType::Range, "toSource: the value has too many entries"));
}
Ok(())
}
fn value(&mut self, interp: &mut Interp, v: &Value) -> JsResult<()> {
interp.use_fuel()?;
match v {
Value::Undefined => self.push(interp, "(void 0)"),
Value::Null => self.push(interp, "null"),
Value::Bool(b) => self.push(interp, if *b { "true" } else { "false" }),
Value::Number(n) if *n == 0.0 && n.is_sign_negative() => self.push(interp, "-0"),
Value::Number(n) => {
self.out.push_units(interp, &number_to_str(*n).flat())?;
self.check_length(interp)
}
Value::String(s) => self.quoted(interp, &s.flat()),
Value::Object(id) => self.object(interp, *id),
}
}
/// `s` in double quotes, with the characters a string literal cannot hold escaped. The length
/// limit is checked before each addition, so a string that grows as it is escaped throws a
/// `RangeError` before the text takes more heap than the limit allows.
fn quoted(&mut self, interp: &mut Interp, s: &[u16]) -> JsResult<()> {
self.fit(interp, 1)?;
self.out.push_str(interp, "\"")?;
let mut start = 0;
for (i, &c) in s.iter().enumerate() {
let escape = match c {
0x22 => "\\\"".to_string(),
0x5C => "\\\\".to_string(),
0x0A => "\\n".to_string(),
0x0D => "\\r".to_string(),
0x09 => "\\t".to_string(),
0x08 => "\\b".to_string(),
0x0C => "\\f".to_string(),
0..=0x1F | 0x7F | 0x2028 | 0x2029 | 0xD800..=0xDFFF => format!("\\u{c:04X}"),
_ => continue,
};
self.fit(interp, (i - start).saturating_add(escape.len()))?;
self.out.push_units(interp, s.get(start..i).unwrap_or_default())?;
self.out.push_str(interp, &escape)?;
start = i + 1;
}
self.fit(interp, s.len().saturating_sub(start).saturating_add(1))?;
self.out.push_units(interp, s.get(start..).unwrap_or_default())?;
self.push(interp, "\"")
}
fn object(&mut self, interp: &mut Interp, id: ObjId) -> JsResult<()> {
if self.ancestors.contains(&id) {
return Err(interp.type_error("toSource: the value contains itself"));
}
if self.ancestors.len() >= interp.data_limits.depth {
return Err(interp.throw(ErrorType::Range, "toSource: the value is nested too deep"));
}
let (object_proto, array_proto) = (interp.intrinsics.object_proto, interp.intrinsics.array_proto);
let o = interp.object(id)?;
// The text makes an array or an object from `Object.prototype` with enumerable properties
// only, so an array with other properties, an object with another prototype and a property
// that is not enumerable are refused.
let enumerable = o.props.iter().all(|(_, p)| p.enumerable());
let shape = match &o.class {
Class::Array { length, .. }
if o.proto == Some(array_proto) && enumerable && o.props.iter().all(|(k, _)| array_index(k).is_some_and(|i| i < *length)) =>
{
Some(Err(*length))
}
Class::Array { .. } => return Err(interp.type_error(NOT_PLAIN_ARRAY)),
Class::Ordinary if o.proto == Some(object_proto) && enumerable => {
Some(Ok(o.props.iter().map(|(k, p)| (k.clone(), p.clone())).collect::<Vec<_>>()))
}
Class::Ordinary => return Err(interp.type_error(NOT_PLAIN_OBJECT)),
_ => None,
};
let Some(shape) = shape else {
let what = format!("toSource: a {} cannot be written as source", interp.object(id)?.class.name());
return Err(interp.type_error(&what));
};
self.ancestors.push(id);
let r = match shape {
Err(length) => self.array(interp, id, length),
Ok(props) => self.plain(interp, props),
};
self.ancestors.pop();
r
}
fn array(&mut self, interp: &mut Interp, id: ObjId, length: u32) -> JsResult<()> {
self.count(interp, length as usize)?;
self.push(interp, "[")?;
let mut hole = false;
for i in 0..length {
interp.use_fuel()?;
if i > 0 {
self.push(interp, ", ")?;
}
let prop = interp.object(id)?.props.get(&index_key(i)).cloned();
hole = prop.is_none();
match prop {
None => {}
Some(Prop::Data { value, .. }) => self.value(interp, &value)?,
Some(Prop::Accessor { .. }) => return Err(interp.type_error("toSource: a getter cannot be written as source")),
}
}
// A hole at the end needs a comma of its own, or the array would be one shorter.
if hole {
self.push(interp, ",")?;
}
self.push(interp, "]")
}
fn plain(&mut self, interp: &mut Interp, props: Vec<(crate::heap::Key, Prop)>) -> JsResult<()> {
self.count(interp, props.len())?;
self.push(interp, "({")?;
for (n, (k, prop)) in props.into_iter().enumerate() {
interp.use_fuel()?;
if n > 0 {
self.push(interp, ", ")?;
}
let Prop::Data { value, .. } = prop else { return Err(interp.type_error("toSource: a getter cannot be written as source")) };
self.quoted(interp, &k)?;
self.push(interp, ":")?;
self.value(interp, &value)?;
}
self.push(interp, "})")
}
}
const NOT_PLAIN_ARRAY: &str =
"toSource: an array with properties besides its elements, a property that is not enumerable or another prototype cannot be written as source";
const NOT_PLAIN_OBJECT: &str =
"toSource: an object with a property that is not enumerable, or whose prototype is not Object.prototype, cannot be written as source";
/// The getter behind `item`; [`ScriptItem::Global`] is a value and has none.
pub(crate) fn getter(item: ScriptItem) -> Option<(&'static str, NativeFn)> {
match item {
ScriptItem::Global => None,
ScriptItem::LastError => Some(("error", last_error)),
ScriptItem::Line => Some(("line", line)),
ScriptItem::Stack => Some(("stack", stack)),
}
}
fn last_error(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult<Value> {
Ok(interp.last_error.clone())
}
fn line(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult<Value> {
let line = interp.script_frames().last().map_or(0, |(code, pc, _)| code.line_column(pc.saturating_sub(1)).0);
Ok(Value::Number(f64::from(line)))
}
/// The most frames `$.stack` lists, and the most characters of a function's name it shows.
const STACK_FRAMES: usize = 64;
const STACK_NAME_UNITS: usize = 64;
/// The script's frames, outermost first, one a line: the function's name and the line it is at.
fn stack(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult<Value> {
let frames: Vec<(Rc<Code>, usize, bool)> = interp.script_frames().map(|(c, pc, f)| (c.clone(), pc, f)).collect();
let skip = frames.len().saturating_sub(STACK_FRAMES);
let mut text = String::new();
if skip > 0 {
text.push_str("…\n");
}
for (code, pc, function) in frames.iter().skip(skip) {
interp.use_fuel()?;
let name = if *function { function_name(code) } else { "[global]".to_string() };
let line = code.line_column(pc.saturating_sub(1)).0;
text.push_str(&format!("{name}:{line}\n"));
}
let mut out = Builder::new();
out.push_str(interp, &text)?;
Ok(out.finish(interp))
}
/// The name `code` has: its own as a function expression's, or its declaration's, cut to
/// [`STACK_NAME_UNITS`]; `[anonymous]` without one.
fn function_name(code: &Code) -> String {
match code.self_name.as_ref().or(code.decl_name.as_ref()) {
Some(name) => String::from_utf16_lossy(name.get(..STACK_NAME_UNITS).unwrap_or(&**name)),
None => "[anonymous]".to_string(),
}
}
+2
View File
@@ -195,6 +195,8 @@ pub(crate) struct Code {
pub(crate) strict: bool,
/// A function expression's own name, bound inside it.
pub(crate) self_name: Option<Key>,
/// A function declaration's name, which `$.stack` shows.
pub(crate) decl_name: Option<Key>,
pub(crate) params: Vec<Key>,
pub(crate) vars: Vec<Key>,
/// Function declarations to instantiate on entry: their names and indices in `functions`.
+3
View File
@@ -1193,6 +1193,7 @@ impl<'a> FnCompiler<'a> {
max_stack: 0,
strict: self.strict,
self_name: meta.self_name,
decl_name: meta.decl_name,
params: meta.params,
vars: hoisted.vars,
fun_decls: hoisted.fun_decls,
@@ -1209,6 +1210,7 @@ impl<'a> FnCompiler<'a> {
#[derive(Default)]
struct Meta {
self_name: Option<Key>,
decl_name: Option<Key>,
params: Vec<Key>,
span: (u32, u32),
}
@@ -1231,6 +1233,7 @@ pub(crate) fn compile_function(ast: &Ast, fid: FunctionId, source: &Rc<Source>,
c.emit(Op::Return);
let name = f.name.as_ref().map(|n| key_of(&n.name));
let meta = Meta {
decl_name: if f.kind == FunctionKind::Declaration { name.clone() } else { None },
self_name: if bind_name && f.kind == FunctionKind::Expression { name } else { None },
params: f.params.iter().map(|p| key_of(&p.name)).collect(),
span: (f.span.start, f.span.end),
+202
View File
@@ -0,0 +1,202 @@
//! The script extensions: `localize`, `toSource`, host properties with setters and the items that
//! read the interpreter's own state.
use std::cell::RefCell;
use std::rc::Rc;
use super::*;
fn realm() -> Realm {
let mut r = Realm::new();
r.add_script_extensions().unwrap();
r
}
fn text(r: &mut Realm, source: &str) -> String {
match r.run(source) {
Ok(JsValue::String(s)) => s,
other => panic!("{source}: {other:?}"),
}
}
fn thrown(r: &mut Realm, source: &str) -> String {
match r.run(source) {
Err(e) if e.kind == RunErrorKind::Thrown => e.message,
other => panic!("{source}: {other:?}"),
}
}
#[test]
fn localize_looks_up_the_locale_with_its_platform_then_its_language_then_english() {
let mut r = realm();
let l = r.localization();
l.set_locale("de_DE");
l.set_platform(Some("Win"));
assert_eq!(l.keys(), ["de_DE_Win", "de_DE", "de_Win", "de", "en"]);
let all = "({en: 'e', de: 'd', de_Win: 'dw', de_DE: 'dd', de_DE_Win: 'ddw'})";
assert_eq!(text(&mut r, &format!("localize({all})")), "ddw");
assert_eq!(text(&mut r, "localize({en: 'e', de: 'd', de_Win: 'dw', de_DE: 'dd'})"), "dd");
assert_eq!(text(&mut r, "localize({en: 'e', de: 'd', de_Win: 'dw'})"), "dw");
assert_eq!(text(&mut r, "localize({en: 'e', de: 'd', DE: 'x'})"), "d", "names are case sensitive");
assert_eq!(text(&mut r, "localize({en: 'e', fr: 'f'})"), "e");
l.set_platform(None);
assert_eq!(text(&mut r, &format!("localize({all})")), "dd");
l.set_locale("en");
assert_eq!(l.keys(), ["en"]);
}
#[test]
fn localize_replaces_numbered_values_once_and_leaves_the_rest() {
let mut r = realm();
assert_eq!(text(&mut r, "localize({en: '%2 before %1, %1 again'}, 'a', 'b')"), "b before a, a again");
assert_eq!(text(&mut r, "localize('%1%% %3 %0 %', 'x', '%1')"), "x%% %3 %0 %");
assert_eq!(text(&mut r, "localize('%2', 'x', '%1')"), "%1", "a value is not replaced again");
assert_eq!(text(&mut r, "localize('%1', {toString: function () { return 'o'; }})"), "o");
assert_eq!(text(&mut r, "var n = 0; var v = {toString: function () { n++; return 'v'; }}; localize('%1%1', v) + n"), "vv1");
}
#[test]
fn localize_gives_back_an_object_without_the_locale_and_runs_no_getter() {
let mut r = realm();
r.localization().set_locale("fr_FR");
assert_eq!(r.run("var o = {de: 'd'}; localize(o) === o").unwrap(), JsValue::Bool(true));
let source = "var hit = false; var o = {get fr() { hit = true; return 'g'; }, en: 'e'}; localize(o) + ',' + hit";
assert_eq!(text(&mut r, source), "e,false");
assert_eq!(text(&mut r, "localize({fr: 1, en: 'e'})"), "e", "a value that is not a string is passed over");
for bad in ["localize()", "localize(1)", "localize(null)"] {
assert!(thrown(&mut r, bad).starts_with("TypeError"), "{bad}");
}
}
#[test]
fn to_string_localizes_an_object_only_while_localization_is_on() {
let mut r = realm();
r.localization().set_locale("ja");
let source = "String({en: 'Hello', ja: 'こんにちは'}) + '|' + String({x: 1})";
assert_eq!(text(&mut r, source), "[object Object]|[object Object]");
r.localization().set_enabled(true);
assert_eq!(text(&mut r, source), "こんにちは|[object Object]");
}
#[test]
fn to_source_writes_plain_data_as_an_expression_that_makes_it_again() {
let mut r = realm();
let source = r#"toSource({a: [1, "x\n\"", null, true, -0, 1.5e300], "b c": {d: undefined}, e: {}, f: []})"#;
let want = r#"({"a":[1, "x\n\"", null, true, -0, 1.5e+300], "b c":({"d":(void 0)}), "e":({}), "f":[]})"#;
assert_eq!(text(&mut r, source), want);
assert_eq!(text(&mut r, "[1, , 3].toSource() + '|' + [1, ,].toSource() + '|' + [,].toSource()"), "[1, , 3]|[1, ,]|[,]");
assert_eq!(
text(&mut r, "toSource(NaN) + toSource(-Infinity) + toSource() + toSource('\\u2028\\ud800\\x7f')"),
"NaN-Infinity(void 0)\"\\u2028\\uD800\\u007F\""
);
assert_eq!(text(&mut r, "(5).toSource() + 'a'.toSource() + true.toSource()"), "5\"a\"true");
// Parsed back, the text makes the same value: holes, -0 and undefined included.
let round = r#"var v = {a: [1, , "q\u0001'\\"], b: {"0": -0, "": undefined, "x y": [[]]}};
var w = eval(toSource(v));
[JSON.stringify(w) === JSON.stringify(v), w.a.length, 1 in w.a, 1 / w.b[0], "" in w.b].join()"#;
assert_eq!(text(&mut r, round), "true,3,false,-Infinity,true");
let shared = "var s = {k: 1}; toSource([s, s])";
assert_eq!(text(&mut r, shared), r#"[({"k":1}), ({"k":1})]"#, "a value seen twice that is not its own ancestor is written twice");
}
#[test]
fn to_source_refuses_what_is_not_plain_data() {
let mut r = realm();
for (source, kind) in [
("var o = {}; o.o = o; toSource(o)", "TypeError"),
("var a = [1]; a.push([a]); toSource(a)", "TypeError"),
("toSource({f: function () {}})", "TypeError"),
("toSource({get g() { return 1; }})", "TypeError"),
("toSource(new Date(0))", "TypeError"),
("toSource([/x/])", "TypeError"),
("toSource(new Error('e'))", "TypeError"),
("toSource(new String('s'))", "TypeError"),
("var a = [1]; a.extra = 2; toSource(a)", "TypeError"),
("toSource(Object.create(Array.prototype))", "TypeError"),
("var o = {}; Object.defineProperty(o, 'hidden', {value: 1}); toSource(o)", "TypeError"),
("var a = [0]; Object.defineProperty(a, '0', {value: 1, enumerable: false}); toSource(a)", "TypeError"),
("toSource(Object.create({a: 1}))", "TypeError"),
("toSource(Object.create(null))", "TypeError"),
("function F() { this.a = 1; } toSource(new F())", "TypeError"),
("var d = []; for (var i = 0; i < 70; i++) d = [d]; toSource(d)", "RangeError"),
("var a = []; a.length = 2000000; toSource(a)", "RangeError"),
] {
assert!(thrown(&mut r, source).starts_with(kind), "{source}");
}
assert!(thrown(&mut r, "toSource({get g() { throw 'ran'; }})").starts_with("TypeError"), "the getter does not run");
}
#[test]
fn to_source_of_a_string_with_many_escapes_throws_a_range_error_before_the_heap_limit() {
let mut r = realm();
// A run's heap in the web build. 2^23 control characters, each written as six, pass a quarter
// of it, which is all one string may take.
r.set_limits(Limits { heap_bytes: 128 << 20, ..Limits::default() });
let source = "var s = '\\x01'; for (var i = 0; i < 23; i++) s = s + s;
try { toSource(s); 'no error'; } catch (e) { e.name + ': ' + e.message; }";
assert_eq!(text(&mut r, source), "RangeError: toSource: the source is too long");
assert_eq!(text(&mut r, "toSource('a\\x01a\\x01a\\x01a\\x01').length + ''"), "30", "short strings are written whole");
}
#[test]
fn to_source_stops_at_the_fuel_limit() {
let mut r = realm();
r.set_limits(Limits { fuel: 200_000, ..Limits::default() });
let e = r.run("var a = []; for (var i = 0; i < 1000; i++) a.push(i); while (true) toSource(a);").unwrap_err();
assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel));
}
#[test]
fn script_items_read_the_global_object_the_last_error_the_line_and_the_stack() {
let mut r = realm();
let items = ["global", "error", "line", "stack"]
.into_iter()
.zip([ScriptItem::Global, ScriptItem::LastError, ScriptItem::Line, ScriptItem::Stack])
.map(|(n, i)| (n.to_string(), HostItem::Script(i)))
.collect();
r.define_host_object("h", items).unwrap();
assert_eq!(r.run("var later = 2; h.global === this && h.global.later === 2").unwrap(), JsValue::Bool(true));
let source = "var before = h.error; try { null.x; } catch (e) {} var caught = h.error; try { throw 'text'; } catch (e) {}
[before === null, caught instanceof TypeError, h.error === caught].join()";
assert_eq!(text(&mut r, source), "true,true,true");
let source = "try { try { throw new RangeError('first'); } finally { try { throw new TypeError('second'); } catch (e) {} } } catch (e) {}
h.error.message";
assert_eq!(text(&mut r, source), "second", "an exception a finally block passes on is not thrown again");
let source = "try { [1].map(function () { try { throw new RangeError('first'); } finally { try { throw new TypeError('second'); } catch (e) {} } }); } catch (e) {}
h.error.message";
assert_eq!(text(&mut r, source), "second", "nor is one that leaves a built-in's callback");
let source = "var kept; try { throw new RangeError('first'); } catch (e) { kept = e; } try { throw new TypeError('second'); } catch (e) {}
try { throw kept; } catch (e) {} h.error.message";
assert_eq!(text(&mut r, source), "first", "throwing a caught error again makes it the last one");
assert_eq!(r.run("h.error").unwrap(), JsValue::Null, "each run starts without one");
assert_eq!(r.run("\n\n h.line").unwrap(), JsValue::Number(3.0));
let source = "function outer() {\n return inner();\n}\nfunction inner() {\n return h.stack;\n}\nvar f = function () { return outer(); };\nf()";
assert_eq!(text(&mut r, source), "[global]:8\n[anonymous]:7\nouter:2\ninner:5\n");
let source = "function maker() {\n function named() {\n return h.stack;\n }\n return named;\n}\nvar later = maker();\nlater()";
assert_eq!(text(&mut r, source), "[global]:8\nnamed:3\n", "a declared function keeps its name after its declaring function returns");
let deep = "function d(n) { return n ? d(n - 1) : h.stack; } var s = d(100).split('\\n'); s.length + ',' + s[0]";
let lines = text(&mut r, deep);
assert_eq!(lines, "66,…", "64 frames, the line that some are left out, and the last line's end");
assert!(thrown(&mut r, "'use strict'; h.line = 1").starts_with("TypeError"), "the items cannot be assigned");
}
#[test]
fn a_host_property_reads_its_getter_and_gives_assignments_to_its_setter() {
let mut r = Realm::new();
let seen: Rc<RefCell<Vec<Data>>> = Rc::default();
let log = seen.clone();
let get: HostFunction = Rc::new(move |_| Ok(Data::String("got".into())));
let set: HostFunction = Rc::new(move |args| match args.first() {
Some(Data::Number(n)) if *n < 0.0 => Err(HostError::Range("no negatives".into())),
Some(d) => {
log.borrow_mut().push(d.clone());
Ok(Data::Undefined)
}
None => Ok(Data::Undefined),
});
r.define_host_object("h", vec![("p".into(), HostItem::Property { get, set })]).unwrap();
assert_eq!(r.run("h.p = 5; h.p = {a: [1]}; h.p").unwrap(), JsValue::String("got".into()));
assert_eq!(*seen.borrow(), [Data::Number(5.0), Data::Object(vec![("a".into(), Data::Array(vec![Data::Number(1.0)]))])]);
let e = r.run("try { h.p = -1; } catch (e) { e instanceof RangeError && e.message }").unwrap();
assert_eq!(e, JsValue::String("no negatives".into()));
}
+1 -1
View File
@@ -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));
+35
View File
@@ -109,6 +109,12 @@ pub enum HostItem {
Converted(Vec<Arg>, HostFunction),
/// A property whose value the function gives each time it is read.
Getter(HostFunction),
/// A property whose value `get` gives each time it is read, and that `set` is given each value
/// assigned to it, as its one argument; an error from `set` is thrown to the script.
Property {
get: HostFunction,
set: HostFunction,
},
/// A frozen object with these members.
Object(Vec<(String, HostItem)>),
/// A function whose host function gives JavaScript source as a string, which then runs as
@@ -123,6 +129,23 @@ pub enum HostItem {
end: Rc<dyn Fn()>,
strict_writes: Rc<dyn Fn() -> Option<bool>>,
},
/// A read-only property that reads the interpreter's own state.
Script(ScriptItem),
}
/// The interpreter's own state a host object can show.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ScriptItem {
/// The global object itself, with every global the script has made.
Global,
/// The last `Error` object thrown in the current run, caught or not, or `null` before one.
LastError,
/// The line of the script's code that reads it, counted from 1.
Line,
/// The running frames of the script's code, outermost first, one a line as `name:line`: the
/// global code is `[global]` and a function without a name `[anonymous]`. At most 64 frames
/// are listed, the innermost ones, after a line `…` when some are left out.
Stack,
}
enum Step {
@@ -484,6 +507,11 @@ 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 },
@@ -496,6 +524,13 @@ impl Interp {
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)
}
+27
View File
@@ -164,6 +164,13 @@ pub(crate) struct Interp {
pub(crate) throw_site: Option<ThrowSite>,
/// The last exception a run did not catch.
pub(crate) last_thrown: Value,
/// The last `Error` object thrown in this run, caught or not, or `null` (`$.error`).
pub(crate) last_error: Value,
/// An exception passed on rather than thrown again: one a `finally` block restores, or one
/// leaving a nested run. [`Interp::unwind`] does not make it [`Interp::last_error`] again.
pub(crate) passing: Value,
/// The locale `localize` and `Object.prototype.toString` read.
pub(crate) localization: Rc<crate::builtins::extensions::Localization>,
/// The state of `Math.random`'s generator.
pub(crate) random: u64,
/// The objects `Array.prototype.join` and `toLocaleString` are joining, innermost last: a
@@ -393,6 +400,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,
@@ -579,6 +589,8 @@ impl Interp {
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);
@@ -666,6 +678,13 @@ 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)) {
@@ -694,11 +713,18 @@ impl Interp {
let Some(frame) = self.frames.pop() else { return Err(Abrupt::Throw(v)) };
self.stack.truncate(frame.base);
if frame.boundary {
self.passing = v.clone();
return Err(Abrupt::Throw(v));
}
}
}
/// Each running frame of script code, outermost first: its code, the index of its next
/// instruction, and whether it runs a function (not global or eval code).
pub(crate) fn script_frames(&self) -> impl Iterator<Item = (&Rc<Code>, usize, bool)> {
self.frames.iter().map(|f| (&f.code, f.pc, f.callee.is_some()))
}
fn abort_to_boundary(&mut self) {
while let Some(frame) = self.frames.pop() {
self.stack.truncate(frame.base);
@@ -886,6 +912,7 @@ impl Interp {
}
if kind == 1.0 {
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)
{
+1
View File
@@ -843,6 +843,7 @@ fn code(ops: Vec<Op>) -> Code {
max_stack: 0,
strict: false,
self_name: None,
decl_name: None,
params: Vec::new(),
vars: Vec::new(),
fun_decls: Vec::new(),
+4 -1
View File
@@ -66,8 +66,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};
@@ -188,6 +189,8 @@ impl LineTable {
#[cfg(test)]
mod builtins_tests;
#[cfg(test)]
mod extension_tests;
#[cfg(test)]
mod host_tests;
#[cfg(test)]
mod interp_tests;
+18
View File
@@ -229,6 +229,7 @@ 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;
@@ -262,6 +263,7 @@ impl Realm {
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)) => {
@@ -412,6 +414,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.
@@ -422,6 +435,11 @@ impl Realm {
}
}
/// The locale `localize` reads, which the host's functions may change during a run.
pub fn localization(&self) -> std::rc::Rc<crate::Localization> {
self.interp.localization.clone()
}
/// Defines the global variable `name` holding the exception the last run did not catch
/// (`undefined` if there was none).
pub fn bind_last_thrown(&mut self, name: &str) {
@@ -156,8 +156,6 @@ impl<'s, 'a, 'l> Analyzer<'s, 'a, 'l> {
} else if n == catalog::DOLLAR_EVAL_FILE {
self.acc.feature(FeatureId::DollarEvalFile, line);
self.acc.outside(OutsideId::UnseenCode, line, None);
} else if n == catalog::LOCALIZE {
self.acc.feature(FeatureId::Localize, line);
} else {
self.acc.feature(FeatureId::Dollar, line);
}
+2
View File
@@ -16,6 +16,8 @@ pub(crate) const DOLLAR_EVAL_FILE: &str = "evalFile";
// https://extendscript.docsforadobe.dev/extendscript-tools-features/user-notification-dialogs/
pub(crate) const NOTIFY: &[&str] = &["alert", "confirm", "prompt"];
// The global function. `$.localize` is a property of `$` (the Dollar object page above), so it is
// a `$` member:
// https://extendscript.docsforadobe.dev/extendscript-tools-features/localizing-extendscript-strings/
pub(crate) const LOCALIZE: &str = "localize";
+10 -1
View File
@@ -35,6 +35,7 @@ const FEATURE_CASES: &[(&str, &str, &str)] = &[
("directive.target", " //@target acme", "x(); //@target acme"),
("directive.targetengine", "#targetengine session", "var targetengine = 'session';"),
("dollar", "$.writeln('hi');", "log.writeln('hi');"),
("dollar", "$.localize = true;", "obj.localize = true;"),
("dollar.env", "var home = $.getenv('HOME');", "var home = env.getenv('HOME');"),
("dollar.evalFile", "$.evalFile(f);", "loader.evalFile(f);"),
("dynamicCode", "eval(code);", "safe.eval(code);"),
@@ -68,7 +69,6 @@ const FEATURE_CASES: &[(&str, &str, &str)] = &[
("file.write", "Folder.temp.remove();", "cache.temp.remove();"),
("hostCheck", "if (parseInt(app.version) < 20) {}", "if (parseInt(appVersion) < 20) {}"),
("localize", "var s = localize(text);", "var s = text.localize;"),
("localize", "var s = $.localize(text);", "var s = obj.localize(text);"),
("menuCommand", "app.executeMenuCommand('acmecopyall');", "executeMenuCommand('acmecopyall');"),
("notify", "alert('done');", "panel.alert('done');"),
("notify", "if (confirm('Go?')) {}", "function confirm(m) {}"),
@@ -103,6 +103,15 @@ fn each_feature_is_found_where_it_is_and_nowhere_else() {
}
}
#[test]
fn the_dollar_objects_localize_property_is_not_the_global_localize_function() {
for src in ["$.localize = true;", "if ($.localize) {}", "var s = $.localize(text);"] {
let r = report(src);
assert!(feature(&r, "dollar").is_some(), "{src}: {:?}", r.features);
assert!(feature(&r, "localize").is_none(), "{src}: {:?}", r.features);
}
}
#[test]
fn features_are_listed_in_alphabetical_order_of_their_ids() {
let ids: Vec<&str> = FeatureId::ALL.iter().map(|f| f.id()).collect();
+41
View File
@@ -1194,6 +1194,47 @@ impl Targets {
(d, ov, held.contains(&true))
}
/// [`Self::snap_moved_to_rulers`] for a selection a move Shift keeps to its line (through the
/// grabbed point `p`, along `dir`), its bounds now `r`: the edge or centre of `r` that a ruler
/// guide runs past within `tol` goes onto it by sliding along the line, the way needing the
/// shortest slide winning and at most twice `tol` (a line nearly parallel to a guide would
/// carry the selection far to meet it). Returns the shift along the line and the guides.
pub(crate) fn snap_moved_along_to_rulers(&self, p: Point, r: Rect, dir: Vec2, tol: f64) -> (Vec2, Vec<Overlay>) {
let len = dir.hypot();
if !len.is_finite() || len <= 1e-9 {
return (Vec2::ZERO, vec![]);
}
let u = dir / len;
// The way along the line, where the guide is and where it starts, and whether it is upright.
let mut best: Option<(f64, f64, Point, bool)> = None;
for (vertical, c) in [(true, u.x), (false, u.y)] {
if c.abs() <= 1e-9 {
continue;
}
let vs = if vertical { [r.x0, r.center().x, r.x1] } else { [r.y0, r.center().y, r.y1] };
let (mid, half) =
if vertical { (Point::new(0.0, r.center().y), r.height() / 2.0) } else { (Point::new(r.center().x, 0.0), r.width() / 2.0) };
for (t, from, _) in self.rulers.iter().filter(|g| g.vertical == vertical).filter_map(|g| g.line_at(mid, tol + half)) {
for v in vs {
let s = (t - v) / c;
if (t - v).abs() <= tol && s.abs() <= 2.0 * tol && best.is_none_or(|b| s.abs() < b.0.abs()) {
best = Some((s, t, from, vertical));
}
}
}
}
let Some((s, t, from, vertical)) = best else { return (Vec2::ZERO, vec![]) };
let moved = r + u * s;
let line = if vertical {
self.line(Point::new(t, from.y.min(moved.y0)), Point::new(t, from.y.max(moved.y1)))
} else {
self.line(Point::new(from.x.min(moved.x0), t), Point::new(from.x.max(moved.x1), t))
};
let mut ov: Vec<Overlay> = line.into_iter().collect();
ov.extend(self.label(p + u * s, "align"));
(u * s, ov)
}
/// Snap a bounding-box resize. `a` is the scale [`crate::bbox::scale_for_drag`] gave for
/// dragging `handle` of the box `bx` (about the centre when `from_center`), in the box's own
/// frame, and so is the result. The handle lands on an anchor or centre within `tol` on the page
+19
View File
@@ -22,6 +22,25 @@ fn snaps_to_anchor_and_alignment() {
assert_eq!(p.y, 200.0);
}
/// #4312: a Shift move slides the selection along its line onto a ruler guide, but not far to
/// meet one a nearly parallel line only reaches a long way off.
#[test]
fn a_slide_along_a_line_lands_the_bounds_on_a_ruler_guide_within_reach() {
let t = Targets { rulers: vec![Ruler { vertical: true, pos: 300.0, span: None }], ..Targets::default() };
let r = Rect::new(248.0, 10.0, 298.0, 40.0);
let p = Point::new(270.0, 25.0);
// Level: the right edge is 2 short of the guide, which is the way along the line.
assert_eq!(t.snap_moved_along_to_rulers(p, r, Vec2::new(1.0, 0.0), 4.0).0, Vec2::new(2.0, 0.0));
// On the diagonal it is 2.8 along.
let (d, ov) = t.snap_moved_along_to_rulers(p, r, Vec2::new(1.0, 1.0), 4.0);
assert!((d - Vec2::new(2.0, 2.0)).hypot() < 1e-9 && !ov.is_empty(), "{d:?}");
// Nearly upright, it would take about 118 along the line to meet the guide.
assert_eq!(t.snap_moved_along_to_rulers(p, r, Vec2::new(0.017, 1.0), 4.0).0, Vec2::ZERO);
// A guide of an artboard needs the selection to run past it.
let bounded = Targets { rulers: vec![Ruler { vertical: true, pos: 300.0, span: Some((100.0, 200.0)) }], ..Targets::default() };
assert_eq!(bounded.snap_moved_along_to_rulers(p, r, Vec2::new(1.0, 0.0), 4.0).0, Vec2::ZERO);
}
#[test]
fn rect_snap_aligns_edges() {
let (d, id) = doc_with_rect();
+8
View File
@@ -368,6 +368,14 @@ impl MoveSnap {
if q != start + d {
d = q - start;
guides = ov;
} else if guides.is_empty()
&& let Some(b) = self.bounds
{
// Nothing else lined up: the bounds slide along the line onto a ruler guide, as
// they do without Shift (#4312).
let (adj, ov) = t.snap_moved_along_to_rulers(start + d, b + d, u, cx.tol(cx.snap_tolerance));
d += adj;
guides = ov;
}
}
if cx.snap_to_pixel
+51
View File
@@ -770,6 +770,30 @@ fn a_move_snaps_the_grabbed_point_to_a_ruler_guide_with_smart_guides_on() {
assert_eq!(dx(&ToolContext { snap_to_point: false, ..cx(&d, &s, &p) }, 172.0), 22.0);
}
/// #4312: a Shift move keeps to its line while its edges and centre land on a ruler guide, as a
/// move without Shift does (#5746).
#[test]
fn a_shift_move_lands_the_edges_on_a_ruler_guide() {
let (mut d, id) = doc_with_rect();
d.guides = vec![vectorcraft_doc::Guide::new(true, 333.0)];
let mut s = Selection::default();
s.add(id);
let p = paint();
let c = cx(&d, &s, &p);
// Level; the left edge (100) would end 2 short of the guide, the pointer far from it.
let (m, _) = shift_moved(&c, (150.0 + 231.0, 150.0));
assert!(moved_by(m, (233.0, 0.0)), "{m:?}");
// The right edge, 1 past the guide.
let (m, _) = shift_moved(&c, (150.0 + 134.0, 150.0));
assert!(moved_by(m, (133.0, 0.0)), "{m:?}");
// Out of reach, or with Snap to Point off, the move stays as dragged.
let (m, _) = shift_moved(&c, (150.0 + 221.0, 150.0));
assert!(moved_by(m, (221.0, 0.0)), "{m:?}");
let off = ToolContext { snap_to_point: false, ..cx(&d, &s, &p) };
let (m, _) = shift_moved(&off, (150.0 + 231.0, 150.0));
assert!(moved_by(m, (231.0, 0.0)), "{m:?}");
}
/// Alt pressed during a move makes a copy, which snaps to the original it leaves behind (as
/// gathered before the previews moved it); released, the move doesn't.
#[test]
@@ -2426,3 +2450,30 @@ fn a_ruler_guide_under_the_centre_point_keeps_the_pointer() {
let drag = t.pointer(&c, &ev(PointerKind::Drag, 170.0, 150.0));
assert!(drag.iter().any(|a| matches!(a, Action::Preview(cmd, _) if cmd == "guide.move")), "the guide drags: {down:?} {drag:?}");
}
/// #6437: in Outline view, where an image is drawn as its frame, a click inside it on the edge of
/// a path behind selects that path, with the image selected or not. In Preview the image takes
/// the click.
#[test]
fn outline_view_click_through_an_image_selects_the_path_behind() {
use vectorcraft_doc::{Appearance, Document, ImageObject, Node, NodeKind};
let mut d = Document::new(500.0, 500.0);
let layer = d.layers[0].id;
let (back, image) = (d.alloc_id(), d.alloc_id());
let square = vectorcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0));
d.insert(Some(layer), 0, Node::path(back, square, Appearance::default_art())).unwrap();
let xf = vectorcraft_geom::Affine::translate((150.0, 150.0));
let im = ImageObject { key: "k".into(), width: 100, height: 100, xf, link: None, placement: Default::default(), pixels: None };
d.insert(Some(layer), 1, Node::new(image, NodeKind::Image(Box::new(im)))).unwrap();
let selects = |a: &[Action], id: NodeId| a.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "select.set" && v["ids"] == json!([id.0])));
let p = paint();
for picked in [vec![], vec![image]] {
let mut s = Selection::default();
s.set(picked.clone());
let outline = ToolContext { outline: true, ..cx(&d, &s, &p) };
let a = SelectionTool::default().pointer(&outline, &ev(PointerKind::Down, 200.0, 180.0));
assert!(selects(&a, back), "outline, {picked:?} selected: {a:?}");
let a = SelectionTool::default().pointer(&cx(&d, &s, &p), &ev(PointerKind::Down, 200.0, 180.0));
assert!(!selects(&a, back), "preview, {picked:?} selected: {a:?}");
}
}
+5 -1
View File
@@ -343,7 +343,11 @@ impl VectorcraftApp {
crate::script_runs::poll(self);
crate::scripts_menu::poll(self, ctx);
if self.session.script_running() && !self.session.script_waiting() && self.session.script_dialog().is_none() {
ctx.request_repaint();
// A script in `$.sleep` goes on when its time is up; until then the app can idle.
match self.session.script_sleep_left() {
Some(left) => ctx.request_repaint_after(left),
None => ctx.request_repaint(),
}
}
#[cfg(not(target_arch = "wasm32"))]
self.tick_link_updates(ctx, now);
+4 -2
View File
@@ -136,7 +136,7 @@ pub(super) fn first_tab_shown(widths: &[f32], active: Option<usize>, room: f32)
/// strip shows is the highlighted one. A tab dragged more than [`crate::doc_windows::TEAR_OFF`]
/// above or below the strip floats its view in a window under the pointer; one dragged along the
/// strip moves its document among the others (`document.move`, #5663), a line marking where it
/// will go.
/// will go. A middle-click on a tab closes it, as its × does (#6506).
pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
/// The width of the » button.
const MORE: f32 = 30.0;
@@ -200,7 +200,9 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
icons::paint(ui, "x", xr, if xresp.hovered() { t.text_strong } else { t.text });
ui.painter().galley(egui::pos2(title_left, r.center().y - galley.size().y / 2.0), galley.clone(), t.text);
hovering_art |= crate::document_drag::hover_tab(app, &resp, win, xr.expand(3.0));
if xresp.clicked() {
// A middle-click anywhere on the tab closes it as the × does (#6506), so closing a
// modified document's last view still asks to save first.
if xresp.clicked() || resp.middle_clicked() || xresp.middle_clicked() {
close = Some(win);
} else if resp.clicked() {
activate = Some(win);
+59
View File
@@ -505,6 +505,65 @@ fn a_tabs_close_box_sits_where_the_platform_puts_it() {
assert!(super::CLOSE_BEFORE_TITLE == cfg!(target_os = "macos"));
}
/// A middle-click on a document tab closes it as the × does (#6506): a saved document closes, and
/// a modified one asks to save first instead of losing its changes.
#[test]
fn a_middle_click_on_a_tab_closes_it_as_the_close_box_does() {
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
for _ in 0..3 {
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
}
// The second document has unsaved changes; the third stays the current one.
app.session.set_active(1);
app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
app.session.set_active(2);
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 200.0));
let frame = |app: &mut crate::VectorcraftApp, events: Vec<egui::Event>| {
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::doc_tabs(app, ui));
out.textures_delta.clear();
let mut titles = vec![];
for c in &out.shapes {
if let egui::Shape::Text(t) = &c.shape
&& t.visual_bounding_rect().width() > 30.0
{
titles.push(t.visual_bounding_rect());
}
}
titles.sort_by(|a, b| a.left().total_cmp(&b.left()));
titles
};
let middle_click = |app: &mut crate::VectorcraftApp, at: egui::Pos2| {
let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() };
frame(app, vec![egui::Event::PointerMoved(at), press(true)]);
frame(app, vec![press(false)])
};
let titles = frame(&mut app, vec![]);
assert_eq!(titles.len(), 3, "{titles:?}");
// The modified document's tab: the save prompt comes up and the document stays open.
let titles = middle_click(&mut app, titles[1].center());
assert_eq!(app.session.documents().len(), 3, "a modified document is not closed without asking");
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(crate::unsaved::KIND), "a modified document asks first");
app.ui.dialog = None;
// The first, saved document's tab: it closes, and the others stay open.
let uid = |app: &crate::VectorcraftApp, i: usize| app.session.documents()[i].uid;
let (second, third) = (uid(&app, 1), uid(&app, 2));
middle_click(&mut app, titles[0].center());
assert_eq!(app.session.documents().len(), 2, "the middle-click closed the first document");
assert_eq!((uid(&app, 0), uid(&app, 1)), (second, third));
assert!(app.ui.dialog.is_none(), "a saved document closes without asking");
// A middle-click on the × itself closes too: the third, saved document's.
let titles = frame(&mut app, vec![]);
assert_eq!(titles.len(), 2, "{titles:?}");
let title = titles[1];
let x = if super::CLOSE_BEFORE_TITLE { title.left() - 16.0 } else { title.right() + 20.0 };
middle_click(&mut app, egui::pos2(x, title.center().y));
assert_eq!(app.session.documents().len(), 1, "the middle-click on the × closed the third document");
assert_eq!(uid(&app, 0), second);
assert!(app.ui.dialog.is_none(), "a saved document closes without asking");
}
/// One headless frame of the status bar; returns the artboard navigator's buttons, left to
/// right: first, previous, next, last.
fn navigator(app: &mut crate::VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<egui::Rect> {
+8 -2
View File
@@ -6521,8 +6521,14 @@ breakText Glyphs Глифи
Ended at a limit Прекратен при лимит
Stopped Спрян
Ended by an internal fault Прекратен поради вътрешна грешка
{ms} ms, {calls} host calls {ms} ms, {calls} извиквания към хоста
{dropped} earlier lines dropped, {cut} lines cut {dropped} по-ранни реда премахнати, {cut} реда съкратени
time unavailable времето не е налично
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} най-бавни: {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} извикване към хоста|{n} извиквания към хоста
@plural {n} earlier line dropped|{n} earlier lines dropped {n} по-ранен ред премахнат|{n} по-ранни реда премахнати
@plural {n} line cut|{n} lines cut {n} ред съкратен|{n} реда съкратени
# Deferred plug-in effects
Refresh Deferred Effects Обновяване на отложените ефекти
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -6572,8 +6572,14 @@ shape Open Otvoren
Ended at a limit Završeno na ograničenju
Stopped Zaustavljeno
Ended by an internal fault Završeno zbog interne greške
{ms} ms, {calls} host calls {ms} ms, {calls} poziva hosta
{dropped} earlier lines dropped, {cut} lines cut {dropped} ranijih redova uklonjeno, {cut} redova skraćeno
time unavailable vrijeme nije dostupno
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} najsporije: {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} poziv hosta|{n} poziva hosta|{n} poziva hosta
@plural {n} earlier line dropped|{n} earlier lines dropped {n} raniji red uklonjen|{n} ranija reda uklonjena|{n} ranijih redova uklonjeno
@plural {n} line cut|{n} lines cut {n} red skraćen|{n} reda skraćena|{n} redova skraćeno
# Deferred plug-in effects
Refresh Deferred Effects Osvježi odgođene efekte
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -6776,8 +6776,14 @@ breakText Glyphs Glifs
Ended at a limit Acabat en un límit
Stopped Aturat
Ended by an internal fault Acabat per un error intern
{ms} ms, {calls} host calls {ms} ms, {calls} crides a l’amfitrió
{dropped} earlier lines dropped, {cut} lines cut {dropped} línies anteriors descartades, {cut} línies tallades
time unavailable temps no disponible
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} més lentes: {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} crida a l’amfitrió|{n} crides a l’amfitrió
@plural {n} earlier line dropped|{n} earlier lines dropped {n} línia anterior descartada|{n} línies anteriors descartades
@plural {n} line cut|{n} lines cut {n} línia tallada|{n} línies tallades
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` i `last` han de ser números de línies que la consola conserva, `first` abans de `last`
# Deferred plug-in effects
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -6795,8 +6795,14 @@ breakText Glyphs Glyfy
Ended at a limit Ukončeno na limitu
Stopped Zastaveno
Ended by an internal fault Ukončeno vnitřní chybou
{ms} ms, {calls} host calls {ms} ms, {calls} volání hostitele
{dropped} earlier lines dropped, {cut} lines cut {dropped} starších řádků zahozeno, {cut} řádků zkráceno
time unavailable čas není k dispozici
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} nejpomalejší: {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} volání hostitele|{n} volání hostitele|{n} volání hostitele
@plural {n} earlier line dropped|{n} earlier lines dropped {n} starší řádek zahozen|{n} starší řádky zahozeny|{n} starších řádků zahozeno
@plural {n} line cut|{n} lines cut {n} řádek zkrácen|{n} řádky zkráceny|{n} řádků zkráceno
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` a `last` musí být čísla řádků, které konzole uchovává, `first` před `last`
# Deferred plug-in effects
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -6535,8 +6535,14 @@ breakText Glyphs Glyffer
Ended at a limit Afsluttet ved en grænse
Stopped Stoppet
Ended by an internal fault Afsluttet af en intern fejl
{ms} ms, {calls} host calls {ms} ms, {calls} værtskald
{dropped} earlier lines dropped, {cut} lines cut {dropped} tidligere linjer fjernet, {cut} linjer afkortet
time unavailable tid ikke tilgængelig
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} langsomste: {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} værtskald|{n} værtskald
@plural {n} earlier line dropped|{n} earlier lines dropped {n} tidligere linje fjernet|{n} tidligere linjer fjernet
@plural {n} line cut|{n} lines cut {n} linje afkortet|{n} linjer afkortet
# Deferred plug-in effects
Refresh Deferred Effects Opdatér udskudte effekter
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -6382,8 +6382,14 @@ breakText Glyphs Glyphen
Ended at a limit An einem Limit beendet
Stopped Gestoppt
Ended by an internal fault Durch einen internen Fehler beendet
{ms} ms, {calls} host calls {ms} ms, {calls} Host-Aufrufe
{dropped} earlier lines dropped, {cut} lines cut {dropped} ältere Zeilen verworfen, {cut} Zeilen gekürzt
time unavailable Zeit nicht verfügbar
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} langsamste: {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} Host-Aufruf|{n} Host-Aufrufe
@plural {n} earlier line dropped|{n} earlier lines dropped {n} ältere Zeile verworfen|{n} ältere Zeilen verworfen
@plural {n} line cut|{n} lines cut {n} Zeile gekürzt|{n} Zeilen gekürzt
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` und `last` müssen Nummern von Zeilen sein, die die Konsole enthält, `first` vor `last`
# Deferred plug-in effects
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -6747,8 +6747,14 @@ breakText Glyphs Γλύφοι
Ended at a limit Τερματίστηκε σε όριο
Stopped Διακόπηκε
Ended by an internal fault Τερματίστηκε λόγω εσωτερικού σφάλματος
{ms} ms, {calls} host calls {ms} ms, {calls} κλήσεις προς το κεντρικό σύστημα
{dropped} earlier lines dropped, {cut} lines cut {dropped} παλαιότερες γραμμές απορρίφθηκαν, {cut} γραμμές περικόπηκαν
time unavailable ο χρόνος δεν είναι διαθέσιμος
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} πιο αργές: {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} κλήση προς το κεντρικό σύστημα|{n} κλήσεις προς το κεντρικό σύστημα
@plural {n} earlier line dropped|{n} earlier lines dropped {n} παλαιότερη γραμμή απορρίφθηκε|{n} παλαιότερες γραμμές απορρίφθηκαν
@plural {n} line cut|{n} lines cut {n} γραμμή περικόπηκε|{n} γραμμές περικόπηκαν
@msg `first` and `last` must be numbers of lines the console holds, first before last Τα `first` και `last` πρέπει να είναι αριθμοί γραμμών που κρατά η κονσόλα, το `first` πριν από το `last`
# Deferred plug-in effects
Can't render this file because it is too large.
+8 -2
View File
@@ -6738,8 +6738,14 @@ breakText Glyphs Glifos
Ended at a limit Terminado en un límite
Stopped Detenido
Ended by an internal fault Terminado por un error interno
{ms} ms, {calls} host calls {ms} ms, {calls} llamadas al host
{dropped} earlier lines dropped, {cut} lines cut {dropped} líneas anteriores descartadas, {cut} líneas recortadas
time unavailable tiempo no disponible
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} más lentos: {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} llamada al host|{n} llamadas al host
@plural {n} earlier line dropped|{n} earlier lines dropped {n} línea anterior descartada|{n} líneas anteriores descartadas
@plural {n} line cut|{n} lines cut {n} línea recortada|{n} líneas recortadas
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` y `last` deben ser números de líneas que la consola conserva, `first` antes que `last`
# Deferred plug-in effects
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -6830,8 +6830,14 @@ breakText Glyphs Merkit
Ended at a limit Päättyi rajaan
Stopped Pysäytetty
Ended by an internal fault Päättyi sisäiseen virheeseen
{ms} ms, {calls} host calls {ms} ms, {calls} isäntäkutsua
{dropped} earlier lines dropped, {cut} lines cut {dropped} aiempaa riviä poistettu, {cut} riviä katkaistu
time unavailable aika ei käytettävissä
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} hitaimmat: {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} isäntäkutsu|{n} isäntäkutsua
@plural {n} earlier line dropped|{n} earlier lines dropped {n} aiempi rivi poistettu|{n} aiempaa riviä poistettu
@plural {n} line cut|{n} lines cut {n} rivi katkaistu|{n} riviä katkaistu
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` ja `last` on oltava konsolin säilyttämien rivien numeroita, `first` ennen arvoa `last`
# Deferred plug-in effects
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -6790,8 +6790,14 @@ breakText Glyphs Glyphes
Ended at a limit Terminé à une limite
Stopped Arrêté
Ended by an internal fault Terminé par une erreur interne
{ms} ms, {calls} host calls {ms} ms, {calls} appels à l’hôte
{dropped} earlier lines dropped, {cut} lines cut {dropped} lignes antérieures supprimées, {cut} lignes coupées
time unavailable temps indisponible
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} plus lentes : {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} appel à l’hôte|{n} appels à l’hôte
@plural {n} earlier line dropped|{n} earlier lines dropped {n} ligne antérieure supprimée|{n} lignes antérieures supprimées
@plural {n} line cut|{n} lines cut {n} ligne coupée|{n} lignes coupées
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` et `last` doivent être des numéros de lignes que la console contient, `first` avant `last`
# Deferred plug-in effects
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -8236,8 +8236,14 @@ theme Appearance अपीयरेंस
Ended at a limit सीमा पर समाप्त
Stopped रोका गया
Ended by an internal fault आंतरिक त्रुटि से समाप्त
{ms} ms, {calls} host calls {ms} ms, {calls} होस्ट कॉल
{dropped} earlier lines dropped, {cut} lines cut {dropped} पुरानी पंक्तियाँ हटाई गईं, {cut} पंक्तियाँ काटी गईं
time unavailable समय उपलब्ध नहीं
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} सबसे धीमे: {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} होस्ट कॉल|{n} होस्ट कॉल
@plural {n} earlier line dropped|{n} earlier lines dropped {n} पुरानी पंक्ति हटाई गई|{n} पुरानी पंक्तियाँ हटाई गईं
@plural {n} line cut|{n} lines cut {n} पंक्ति काटी गई|{n} पंक्तियाँ काटी गईं
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` और `last` कंसोल में रखी पंक्तियों की संख्याएँ होनी चाहिए, `first` पहले और `last` बाद में
# Deferred plug-in effects
Can't render this file because it is too large.
+8 -2
View File
@@ -6779,8 +6779,14 @@ breakText Glyphs Glifák
Ended at a limit Korlátnál ért véget
Stopped Leállítva
Ended by an internal fault Belső hiba miatt ért véget
{ms} ms, {calls} host calls {ms} ms, {calls} gazdahívás
{dropped} earlier lines dropped, {cut} lines cut {dropped} korábbi sor elvetve, {cut} sor levágva
time unavailable az idő nem érhető el
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} leglassabbak: {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} gazdahívás
@plural {n} earlier line dropped|{n} earlier lines dropped {n} korábbi sor elvetve
@plural {n} line cut|{n} lines cut {n} sor levágva
@msg `first` and `last` must be numbers of lines the console holds, first before last A `first` és a `last` a konzolban tárolt sorok számai legyenek, a `first` a `last` előtt
# Deferred plug-in effects
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -6535,8 +6535,14 @@ breakText Glyphs Glif
Ended at a limit Berakhir pada batas
Stopped Dihentikan
Ended by an internal fault Berakhir karena galat internal
{ms} ms, {calls} host calls {ms} ms, {calls} panggilan host
{dropped} earlier lines dropped, {cut} lines cut {dropped} baris lama dibuang, {cut} baris dipotong
time unavailable waktu tidak tersedia
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} paling lambat: {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} panggilan host
@plural {n} earlier line dropped|{n} earlier lines dropped {n} baris lama dibuang
@plural {n} line cut|{n} lines cut {n} baris dipotong
# Deferred plug-in effects
Refresh Deferred Effects Segarkan efek yang ditunda
Can't render this file because it contains an unexpected character in line 2327 and column 5.
+8 -2
View File
@@ -6416,8 +6416,14 @@ breakText Glyphs Glifi
Ended at a limit Terminato a un limite
Stopped Interrotto
Ended by an internal fault Terminato per un errore interno
{ms} ms, {calls} host calls {ms} ms, {calls} chiamate all'host
{dropped} earlier lines dropped, {cut} lines cut {dropped} righe precedenti scartate, {cut} righe tagliate
time unavailable tempo non disponibile
{ms} ms {ms} ms
{time}, {calls} {time}, {calls}
slowest: {commands} più lenti: {commands}
{dropped}, {cut} {dropped}, {cut}
@plural {n} host call|{n} host calls {n} chiamata all'host|{n} chiamate all'host
@plural {n} earlier line dropped|{n} earlier lines dropped {n} riga precedente scartata|{n} righe precedenti scartate
@plural {n} line cut|{n} lines cut {n} riga tagliata|{n} righe tagliate
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` e `last` devono essere numeri di righe presenti nella console, `first` prima di `last`
# Deferred plug-in effects
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -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
Can't render this file because it is too large.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+8 -2
View File
@@ -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
Can't render this file because it is too large.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+18
View File
@@ -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"]);
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 182 and column 5.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 204 and column 5.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+15 -8
View File
@@ -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<String> = 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<String> {
(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)])
})
}
@@ -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));
}
}
+8 -3
View File
@@ -304,12 +304,17 @@ fn frames_with_injected_input_are_confined() {
let _ = std::fs::remove_dir_all(base);
}
/// The enabled File › Open Recent Files rows `ui.menu.list` returns: (id, label).
/// 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")) && e["enabled"] == true)
.map(|e| (e["command"].as_str().unwrap_or_default().to_string(), e["label"].as_str().unwrap_or_default().to_string()))
.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()
}
+2 -1
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.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)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.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
@@ -339,6 +339,7 @@ Stop opacity on translated artboards remains outside this regression coverage.
| Date | Change | Summary |
|---|---|---|
| 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) |
+54 -2
View File
@@ -1621,6 +1621,52 @@ VectorCraft reads them by these rules of its own:
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 <version>"`; 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
commands run one at a time. A preference the run changed takes the run's value, and a preference changed in the app
@@ -1796,7 +1842,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
@@ -3811,7 +3862,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;
+1 -1
View File
@@ -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.
+3
View File
@@ -732,6 +732,8 @@ 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: 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: `File` and `Folder` ([File object](https://extendscript.docsforadobe.dev/file-system-access/file-object/), [Folder object](https://extendscript.docsforadobe.dev/file-system-access/folder-object/)) name files and folders by platform or URI path, with `name`, `fsName`, `absoluteURI`, `relativeURI`, `parent`, `Folder.current` and `encode`/`decode`, and read them: `exists`, `length`, the dates, `getFiles` with a mask or a function, and `open('r')` with `read`, `readln`, `readch`, `seek`, `tell` and `close`, decoding by byte order mark, `encoding`, UTF-8 or CP1252. VectorCraft's own rules: a script reads only in its own folder and below (and inside the automation read roots when they are set); any other path and a network or device path are refused before anything is looked up, and a link that leads out is refused as well (`false` or `null`, with `error` set); a file is read whole up to 8 MiB and a run reads up to 32 MiB; writing, the file dialogs and `execute` are not available yet. `crates/engine/src/tests/script_io.rs`.
@@ -2127,6 +2129,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. |
+5 -1
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** 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)
> **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).
>
@@ -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. |