mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 20:01:43 +00:00
Merge main into feat/script-dollar-object
This commit is contained in:
Generated
+1
@@ -6687,6 +6687,7 @@ dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"wasmi",
|
||||
"wat",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1219,7 +1219,9 @@ impl Clipboard {
|
||||
/// 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.
|
||||
/// free name, 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)> {
|
||||
let mut found = BTreeSet::new();
|
||||
for n in &self.nodes {
|
||||
@@ -1244,9 +1246,20 @@ impl Clipboard {
|
||||
let to = if same_doc {
|
||||
(name.clone(), held.xml.clone())
|
||||
} else {
|
||||
let new = unique_name(&name, |n| taken.contains(n));
|
||||
// Free names made from the starts of the name, longest first, then free letters and
|
||||
// digits: the first under which the filter still draws.
|
||||
let mut ends: Vec<usize> = name.char_indices().map(|(i, _)| i).skip(1).collect();
|
||||
ends.push(name.len());
|
||||
let starts = ends.iter().rev().filter_map(|&end| name.get(..end).map(str::trim_end)).filter(|b| !b.is_empty());
|
||||
let shorter = starts.map(|b| unique_name(b, |n| taken.contains(n)));
|
||||
let single = ('a'..='z').chain('0'..='9').map(String::from).filter(|n| !taken.contains(n));
|
||||
let renamed = shorter.chain(single).find_map(|new| {
|
||||
let drawn = vectorcraft_render::effects::svg_filter::with_id(&xml, &new)
|
||||
.filter(|x| vectorcraft_render::effects::svg_filter::check(x).is_ok())?;
|
||||
Some((new, drawn))
|
||||
});
|
||||
let Some((new, drawn)) = renamed else { continue };
|
||||
taken.insert(new.clone());
|
||||
let drawn = vectorcraft_render::effects::svg_filter::with_id(&xml, &new).unwrap_or_else(|| xml.clone());
|
||||
(new, drawn)
|
||||
};
|
||||
links.insert((name, xml), to);
|
||||
|
||||
@@ -270,7 +270,9 @@ fn flattened(src: &Document, at: usize, id: NodeId) -> Result<Option<Flat>> {
|
||||
let pt = im.xf.determinant().abs().sqrt();
|
||||
let ppi = if pt.is_finite() && pt > 0.0 { (72.0 / pt).clamp(MIN_PPI, MAX_PPI) } else { MIN_PPI };
|
||||
let area = (bounds.width() * bounds.height()).max(1.0);
|
||||
let scale = (ppi / 72.0).min((super::super::menucmds::MAX_PIXELS / area).sqrt());
|
||||
// No side longer than the renderer draws (rounded up to whole pixels below).
|
||||
let longest = (f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 2.0) / bounds.width().max(bounds.height()).max(1.0);
|
||||
let scale = (ppi / 72.0).min((super::super::menucmds::MAX_PIXELS / area).sqrt()).min(longest);
|
||||
if !(scale.is_finite() && scale > 0.0) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Print Setup",
|
||||
[],
|
||||
None,
|
||||
"{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), copiesPerSheet: false (not with tiling: as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, not turned; the placement places the grid; an artboard that doesn't fit prints one copy per sheet, with a warning), gutter: 0 (pt between copies, 0–720), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)",
|
||||
"{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), copiesPerSheet: false (not with tiling: as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, not turned; the placement places the grid; an artboard that doesn't fit prints one copy per sheet, with a warning), gutter: 0 (pt between copies, 0–720), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution, or lower for a page larger than 64 Mpx or 32,768 px a side), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)",
|
||||
has_doc,
|
||||
setup
|
||||
),
|
||||
|
||||
@@ -76,9 +76,11 @@ fn bitmap_document(cmd: &str, doc: &Document, set: &PrintSettings, rich_black: b
|
||||
let mut images = vec![];
|
||||
for (_, r) in regions {
|
||||
let area = outset(r, bleed);
|
||||
// At most MAX_PIXELS, and 65535 pixels a side.
|
||||
// At most MAX_PIXELS, and no side longer than the renderer draws (rounded up to whole
|
||||
// pixels below).
|
||||
let side = area.width().max(area.height()).max(1.0);
|
||||
let scale = (doc.raster_effects_ppi / 72.0).min((MAX_PIXELS / (area.width() * area.height()).max(1.0)).sqrt()).min(65_535.0 / side);
|
||||
let longest = f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 2.0;
|
||||
let scale = (doc.raster_effects_ppi / 72.0).min((MAX_PIXELS / (area.width() * area.height()).max(1.0)).sqrt()).min(longest / side);
|
||||
if !(scale.is_finite() && scale > 0.0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -500,9 +500,9 @@ struct Host {
|
||||
script_map: RefCell<include::SourceMap>,
|
||||
/// The same for each source `$.evalFile` gave, in order.
|
||||
eval_maps: RefCell<Vec<include::SourceMap>>,
|
||||
/// The last error `$.evalFile` raised for a file it could not run, with the file, line and
|
||||
/// column that error is at.
|
||||
eval_failure: RefCell<Option<EvalFailure>>,
|
||||
/// The file, line and column of each error `$.evalFile` raised for a file it could not run, in
|
||||
/// order: the error's mark ([`HostError::Marked`]) is its index.
|
||||
eval_failures: RefCell<Vec<(Arc<str>, u32, u32)>>,
|
||||
/// When the run started, where the platform has a clock.
|
||||
started: Option<std::time::Instant>,
|
||||
/// The time since [`Host::started`] at which a `$.sleep` ends.
|
||||
@@ -513,9 +513,6 @@ struct Host {
|
||||
eval_strict: Cell<Option<bool>>,
|
||||
}
|
||||
|
||||
/// An error `$.evalFile` raised, as thrown, and the file, line and column it is at.
|
||||
type EvalFailure = (String, Arc<str>, u32, u32);
|
||||
|
||||
/// A run's host calls by kind, as its report gives them in `hostCallCounts`.
|
||||
#[derive(Default)]
|
||||
struct Counts {
|
||||
@@ -637,6 +634,15 @@ impl Host {
|
||||
HostError::Abort(limit.to_string())
|
||||
}
|
||||
|
||||
/// `error`, marked as an error at `file`, `line` and `column`, the place [`located`] gives when
|
||||
/// the run ends with it uncaught.
|
||||
fn placed(&self, error: HostError, file: Arc<str>, line: u32, column: u32) -> HostError {
|
||||
let mut places = self.eval_failures.borrow_mut();
|
||||
let Ok(mark) = u32::try_from(places.len()) else { return error };
|
||||
places.push((file, line, column));
|
||||
HostError::Marked { error: Box::new(error), mark }
|
||||
}
|
||||
|
||||
fn charge_call(&self, bytes: u64) -> std::result::Result<(), HostError> {
|
||||
let calls = self.calls.get() + 1;
|
||||
self.calls.set(calls);
|
||||
@@ -1052,14 +1058,12 @@ fn eval_file(host: &Host, args: Vec<Data>) -> std::result::Result<Data, HostErro
|
||||
host.charge_call(path.len() as u64 + 2)?;
|
||||
let expanded = host.files.borrow_mut().eval_file(path).map_err(|f| {
|
||||
let message = if f.line > 0 { format!("{}:{}:{}: {}", f.file, f.line, f.column, f.message) } else { f.message };
|
||||
if f.line > 0 {
|
||||
*host.eval_failure.borrow_mut() = Some((message.clone(), f.file.clone(), f.line, f.column));
|
||||
}
|
||||
match f.kind {
|
||||
let error = match f.kind {
|
||||
include::FailKind::Limit => HostError::Abort(message),
|
||||
include::FailKind::Syntax => HostError::Syntax(message),
|
||||
include::FailKind::Error => HostError::Error(message),
|
||||
}
|
||||
};
|
||||
if f.line > 0 { host.placed(error, f.file, f.line, f.column) } else { error }
|
||||
})?;
|
||||
// A file without preprocessor lines is parsed here first, so its syntax error names its place.
|
||||
let options = vectorcraft_js::Options { max_source_units: include::MAX_TOTAL_BYTES, ..vectorcraft_js::Options::default() };
|
||||
@@ -1067,8 +1071,7 @@ fn eval_file(host: &Host, args: Vec<Data>) -> std::result::Result<Data, HostErro
|
||||
host.files.borrow_mut().end_eval();
|
||||
let (_, file, line, column) = expanded.map.locate(e.line, e.column);
|
||||
let message = format!("{file}:{line}:{column}: {}", e.message);
|
||||
*host.eval_failure.borrow_mut() = Some((message.clone(), file, line, column));
|
||||
return Err(HostError::Syntax(message));
|
||||
return Err(host.placed(HostError::Syntax(message), file, line, column));
|
||||
}
|
||||
host.eval_maps.borrow_mut().push(expanded.map);
|
||||
host.eval_strict.set(expanded.strict);
|
||||
@@ -1161,18 +1164,20 @@ fn preprocessor_error(f: include::Failure) -> (RunError, Option<Arc<str>>) {
|
||||
(RunError { kind, message, line: f.line, column: f.column }, file)
|
||||
}
|
||||
|
||||
/// `e`, which the realm raised in source `tag` ([`Realm::error_source`]), at the line and column of
|
||||
/// the file it is in, with that file's name unless it is the script's own.
|
||||
fn located(host: &Host, tag: u32, e: &RunError) -> (RunError, Option<Arc<str>>) {
|
||||
/// `e`, which the realm raised in source `tag` ([`Realm::error_source`]) with host error mark `mark`
|
||||
/// ([`Realm::error_mark`]), at the line and column of the file it is in, with that file's name
|
||||
/// unless it is the script's own.
|
||||
fn located(host: &Host, tag: u32, mark: Option<u32>, e: &RunError) -> (RunError, Option<Arc<str>>) {
|
||||
let map = |m: &include::SourceMap, own: bool| {
|
||||
let (index, file, line, column) = m.locate(e.line, e.column);
|
||||
let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, line, column) } else { e.message.clone() };
|
||||
(RunError { kind: e.kind.clone(), message, line, column }, (!own || index > 0).then_some(file))
|
||||
};
|
||||
// An error `$.evalFile` raised for a file it could not run is at that file's place.
|
||||
if let Some((_, file, line, column)) = host.eval_failure.borrow().as_ref().filter(|(thrown, ..)| e.message.contains(thrown.as_str())) {
|
||||
let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, *line, *column) } else { e.message.clone() };
|
||||
return (RunError { kind: e.kind.clone(), message, line: *line, column: *column }, Some(file.clone()));
|
||||
// An error `$.evalFile` raised for a file it could not run, and no script caught, is at that
|
||||
// file's place.
|
||||
if let Some((file, line, column)) = mark.and_then(|m| host.eval_failures.borrow().get(m as usize).cloned()) {
|
||||
let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, line, column) } else { e.message.clone() };
|
||||
return (RunError { kind: e.kind.clone(), message, line, column }, Some(file));
|
||||
}
|
||||
if e.line == 0 {
|
||||
return (e.clone(), None);
|
||||
@@ -1377,7 +1382,7 @@ impl Session {
|
||||
files: RefCell::new(files),
|
||||
script_map: RefCell::default(),
|
||||
eval_maps: RefCell::default(),
|
||||
eval_failure: RefCell::default(),
|
||||
eval_failures: RefCell::default(),
|
||||
started: clock(),
|
||||
wake_at: Cell::new(None),
|
||||
can_wait: realm.wait_flag(),
|
||||
@@ -1400,7 +1405,7 @@ impl Session {
|
||||
.and_then(|()| realm.define_host_object("$", dollar_object(&host, info, &localization)))
|
||||
.and_then(|()| dialogs::install(&mut realm, &host))
|
||||
.and_then(|()| realm.start(&expanded.source))
|
||||
.map_err(|e| located(&host, realm.error_source(), &e))
|
||||
.map_err(|e| located(&host, realm.error_source(), realm.error_mark(), &e))
|
||||
}
|
||||
};
|
||||
if let Err((e, file)) = started {
|
||||
@@ -1472,7 +1477,7 @@ impl Session {
|
||||
fn script_failed(&mut self, job: Job, e: &RunError) -> Result<Value> {
|
||||
job.host.dialogs.stopped();
|
||||
let fuel_left = job.realm.fuel_left();
|
||||
let (e, file) = located(&job.host, job.realm.error_source(), e);
|
||||
let (e, file) = located(&job.host, job.realm.error_source(), job.realm.error_mark(), e);
|
||||
let mut report = failure_report(&job.name, &e, file.as_deref(), &job.host, fuel_left, job.realm.limits().fuel.saturating_sub(fuel_left));
|
||||
close_run(self, &job.host, &mut report, ending(&e.kind));
|
||||
drop(job);
|
||||
|
||||
@@ -368,6 +368,31 @@ fn a_transparent_image_is_flattened_with_the_art_under_it_which_stays() {
|
||||
assert_eq!(colour_at(page, 10.0, 20.0), [255, 0, 0], "the square outside the image");
|
||||
}
|
||||
|
||||
/// A transparent image longer than the renderer draws at its own resolution (32,768 pixels a
|
||||
/// side) is flattened whole, at a lower resolution.
|
||||
#[test]
|
||||
fn a_long_transparent_image_is_flattened_whole() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 4600})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 4600})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
let p = json!({"name": "i.png", "dataBase64": vectorcraft_format::base64_encode(&png(2000, 1600, [0, 0, 0, 128])), "at": [50, 50]});
|
||||
s.execute("file.place", &p).unwrap();
|
||||
// 10 × 4500 pt: about 600 ppi, so 37,500 pixels long.
|
||||
s.execute("object.setBounds", &json!({"x": 10, "y": 10, "width": 10, "height": 4500, "reference": 0})).unwrap();
|
||||
let before = [colour_at(&s.doc().unwrap().doc, 15.0, 100.0), colour_at(&s.doc().unwrap().doc, 15.0, 4400.0)];
|
||||
assert!(before.iter().all(|c| c[0] < 200), "the image darkens the red: {before:?}");
|
||||
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap();
|
||||
assert!(warnings(&r).iter().any(|w| w.contains("flattened with the art under them")), "{r}");
|
||||
s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let after = [colour_at(d, 15.0, 100.0), colour_at(d, 15.0, 4400.0)];
|
||||
for (a, b) in before.iter().zip(&after) {
|
||||
assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(*b) <= 3), "{before:?} {after:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A `size`×`size` PNG of a black disk of radius `r` pixels with an anti-aliased edge, clear
|
||||
/// around it, 72 ppi.
|
||||
fn disk(size: u32, r: f64) -> Vec<u8> {
|
||||
|
||||
@@ -130,6 +130,28 @@ fn print_as_bitmap_prints_an_image_of_each_artboard() {
|
||||
assert!(crate::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().is_none());
|
||||
}
|
||||
|
||||
/// Print as Bitmap of an artboard longer than the renderer draws at the raster effects resolution
|
||||
/// (32,768 pixels a side) prints an image of all of it, at a lower resolution.
|
||||
#[test]
|
||||
fn print_as_bitmap_prints_all_of_a_long_artboard() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 9000, "height": 100})).unwrap();
|
||||
s.execute("document.rasterEffectsSettings", &json!({"resolution": 300})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 9000, "height": 100})).unwrap();
|
||||
let set = settings(json!({"advanced": {"printAsBitmap": true}}));
|
||||
let saved = s.prefs.flattener_presets.clone();
|
||||
let bitmap = crate::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved, false).unwrap().unwrap();
|
||||
let mut placed = vec![];
|
||||
bitmap.walk(|n| {
|
||||
if let NodeKind::Image(im) = &n.kind {
|
||||
placed.push((im.width, im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)))));
|
||||
}
|
||||
});
|
||||
let [(width, b)] = placed[..] else { panic!("one image: {placed:?}") };
|
||||
assert!(width <= vectorcraft_render::MAX_RASTER_SIDE, "{width} pixels");
|
||||
assert!(b.x0 <= 0.0 && b.x1 >= 9000.0, "the image covers the artboard: {b:?}");
|
||||
}
|
||||
|
||||
/// Appearance of Black › Printing / Exporting (#394): Print as Bitmap draws pure CMYK black as RGB
|
||||
/// black when `rich_black` is on.
|
||||
#[test]
|
||||
|
||||
@@ -509,3 +509,57 @@ fn script_dialogs_in_included_and_evaluated_files() {
|
||||
assert_eq!(report["dialogs"][0]["answer"], true, "{report}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// The line, column and file name of a failed run's report, and its console error line (the only
|
||||
/// line of a run without output that starts with the script's name).
|
||||
fn place(report: &Value) -> (u64, u64, Option<&str>, &str) {
|
||||
let file = report.get("file").map(|f| f.as_str().unwrap().rsplit(['/', '\\']).next().unwrap());
|
||||
let lines = report["console"]["lines"].as_array().unwrap();
|
||||
let error = lines.iter().filter_map(Value::as_str).find(|l| l.starts_with("main.jsx")).unwrap();
|
||||
(report["line"].as_u64().unwrap(), report["column"].as_u64().unwrap(), file, error)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_eval_file_error_caught_and_thrown_again_is_at_the_new_throw() {
|
||||
let base = folder("rethrow");
|
||||
write(&base.join("main/sub/broken.jsx"), "var a = 1;\n\nvar = 2;");
|
||||
write(&base.join("main/sub/directive.jsx"), "\n#frobnicate 1");
|
||||
write(&base.join("main/sub/rethrow.jsx"), "var r = 1;\n throw saved;");
|
||||
write(&base.join("main/sub/outer.jsx"), "var q = 1;\n$.evalFile('broken.jsx');");
|
||||
write(&base.join("main/sub/outer2.jsx"), "try { $.evalFile('broken.jsx'); } catch (e) {\n\n throw e;\n}");
|
||||
let caught = "var saved;\ntry { $.evalFile('sub/broken.jsx'); } catch (e) { saved = e; }\n";
|
||||
let cases = [
|
||||
// Thrown again in the script, or in another file $.evalFile runs.
|
||||
(format!("{caught}var b = 2;\n throw saved;"), (4, 3, None)),
|
||||
(format!("{caught}$.evalFile('sub/rethrow.jsx');"), (2, 3, Some("rethrow.jsx"))),
|
||||
// A new error whose message holds the failure's text, or is the same text.
|
||||
(format!("{caught}throw new Error(saved.message);"), (3, 1, None)),
|
||||
(format!("{caught}throw new SyntaxError(saved.message);"), (3, 1, None)),
|
||||
// A second failure, caught, does not move the first one.
|
||||
(format!("{caught}try {{ $.evalFile('sub/directive.jsx'); }} catch (e) {{}}\n throw saved;"), (4, 2, None)),
|
||||
// A second failure, uncaught, is at its own file's place.
|
||||
(format!("{caught}$.evalFile('sub/directive.jsx');"), (2, 1, Some("directive.jsx"))),
|
||||
// An uncaught failure is at the file's place, through a finally block and from a nested file.
|
||||
("var a = 1;\ntry { $.evalFile('sub/broken.jsx'); } finally { a = 2; }".to_string(), (3, 5, Some("broken.jsx"))),
|
||||
("$.evalFile('sub/outer.jsx');".to_string(), (3, 5, Some("broken.jsx"))),
|
||||
// Caught in a nested file and thrown again there.
|
||||
("$.evalFile('sub/outer2.jsx');".to_string(), (3, 5, Some("outer2.jsx"))),
|
||||
// A failure caught in a run after one that failed with it, and a different error.
|
||||
("try { $.evalFile('sub/broken.jsx'); } catch (e) {}\nthrow new Error('other');".to_string(), (2, 1, None)),
|
||||
];
|
||||
let mut s = Session::new();
|
||||
let main = base.join("main/main.jsx");
|
||||
for (text, (line, column, file)) in cases {
|
||||
write(&main, &text);
|
||||
assert!(s.execute("script.run", &json!({"path": main.to_string_lossy()})).is_err(), "{text}");
|
||||
let report = s.script_report().unwrap().clone();
|
||||
let (l, c, f, error) = place(&report);
|
||||
assert_eq!((report["kind"].as_str(), l, c, f), (Some("thrown"), line, column, file), "{text}\n{report}");
|
||||
let at = match file {
|
||||
Some(f) => format!("{f}:{line}:{column}: "),
|
||||
None => format!("main.jsx:{line}:{column}: "),
|
||||
};
|
||||
assert!(error.starts_with("main.jsx") && error.contains(&at), "{error}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
@@ -259,6 +259,31 @@ fn pasted_art_keeps_a_filter_another_of_its_name_would_replace() {
|
||||
assert_eq!(both, [("Shift".to_string(), SHIFT_MORE.to_string()), ("Shift".to_string(), SHIFT_MORE.to_string())]);
|
||||
}
|
||||
|
||||
/// A pasted filter at the size limit, which "Shift 2" would make too long to draw, keeps its own
|
||||
/// definition under a free name no longer than its own (a free letter for a one-letter name), and
|
||||
/// its id is that name.
|
||||
#[test]
|
||||
fn pasted_art_with_a_filter_at_the_size_limit_still_draws_when_renamed() {
|
||||
for (name, held, renamed) in [("Shift", SHIFT, "Shif"), ("S", r#"<filter id="S"><feOffset dx="3" dy="3"/></filter>"#, "a")] {
|
||||
let mut s = session();
|
||||
run(&mut s, "svgFilter.define", json!({"xml": held}));
|
||||
let (open, close) = (format!(r#"<filter id="{name}"><feOffset dx="9" dy="9" result=""#), r#""/></filter>"#);
|
||||
let big = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len()));
|
||||
assert!(vectorcraft_render::effects::svg_filter::check(&big).is_ok());
|
||||
let svg = format!(
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs>{big}</defs><rect width="30" height="30" filter="url(#{name})"/></svg>"#
|
||||
);
|
||||
run(&mut s, "clipboard.importSvg", json!({"svg": svg}));
|
||||
run(&mut s, "edit.pasteInPlace", json!({}));
|
||||
let pasted = filter_effects(&s);
|
||||
assert_eq!(pasted.len(), 1);
|
||||
assert_eq!(pasted[0].0, renamed, "renamed: {}", pasted[0].0);
|
||||
assert_eq!(vectorcraft_render::effects::svg_filter::check(&pasted[0].1), Ok(renamed.to_string()), "the pasted filter draws under its name");
|
||||
assert!(pasted[0].1.contains("dx=\"9\""), "its own filter");
|
||||
assert_eq!(names(&mut s), [name]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Art in a symbol's definition that uses a filter follows its edits too.
|
||||
#[test]
|
||||
fn a_symbols_art_follows_a_filter_edit() {
|
||||
|
||||
@@ -3449,6 +3449,9 @@ fn typed() -> Document {
|
||||
v_scale: 90.0,
|
||||
stroke: solid(Color::rgb(0.0, 0.5, 0.0)),
|
||||
stroke_width: 0.5,
|
||||
stroke_cap: vectorcraft_doc::LineCap::Square,
|
||||
stroke_join: vectorcraft_doc::LineJoin::Bevel,
|
||||
stroke_miter_limit: 2.5,
|
||||
all_caps: true,
|
||||
..regular.clone()
|
||||
};
|
||||
@@ -3565,7 +3568,7 @@ fn type_lines(doc: &Document) -> Vec<String> {
|
||||
for r in &t.runs {
|
||||
let s = &r.style;
|
||||
out.push(format!(
|
||||
" run {:?} {} {} {} {:?} {} {} {} {} fill {} stroke {} {} caps={} small={:?} tcy={} roman={}",
|
||||
" run {:?} {} {} {} {:?} {} {} {} {} fill {} stroke {} {} {:?} {:?} {} caps={} small={:?} tcy={} roman={}",
|
||||
r.text,
|
||||
s.font_family,
|
||||
s.font_style,
|
||||
@@ -3578,6 +3581,9 @@ fn type_lines(doc: &Document) -> Vec<String> {
|
||||
paint(&s.fill),
|
||||
paint(&s.stroke),
|
||||
r3(s.stroke_width),
|
||||
s.stroke_cap,
|
||||
s.stroke_join,
|
||||
r3(s.stroke_miter_limit),
|
||||
s.all_caps,
|
||||
s.small_caps.map(r3),
|
||||
s.tate_chu_yoko,
|
||||
@@ -3718,6 +3724,22 @@ fn type_comes_back_as_type() {
|
||||
assert_eq!(back.text_threads.len(), 1, "the thread comes back");
|
||||
}
|
||||
|
||||
/// A run without a stroke whose stroke's cap, join and miter limit aren't the defaults loses
|
||||
/// nothing that draws: the save reports no attributes left out.
|
||||
#[test]
|
||||
fn an_unstroked_run_with_a_stroke_style_loses_nothing() {
|
||||
use vectorcraft_doc::{CharStyle, LineCap, LineJoin, TextObject};
|
||||
for miter in [3.0, f64::NAN] {
|
||||
let st = CharStyle { stroke_cap: LineCap::Round, stroke_join: LineJoin::Bevel, stroke_miter_limit: miter, ..style(14.0, Color::BLACK) };
|
||||
let mut doc = Document::new(200.0, 200.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let n = text_node(&mut doc, TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), "Plain", st));
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.is_empty(), "{miter}: {:?}", out.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_is_written_again_as_the_same_bytes() {
|
||||
let doc = typed();
|
||||
|
||||
@@ -21,15 +21,15 @@ use std::fmt::Write;
|
||||
use kurbo::{ParamCurveArclen, PathSeg};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{
|
||||
AreaOptions, Burasagari, CharStyle, Document, FirstBaseline, Justify, Mojikumi, Node, NodeId, NodeKind, ParaDirection, ParaStyle, PathAlign,
|
||||
PathEffect, TextKind, TextObject, VerticalAlign,
|
||||
AreaOptions, Burasagari, CharStyle, Document, FirstBaseline, Justify, LineCap, LineJoin, Mojikumi, Node, NodeId, NodeKind, ParaDirection,
|
||||
ParaStyle, PathAlign, PathEffect, TextKind, TextObject, VerticalAlign,
|
||||
};
|
||||
use vectorcraft_geom::{Affine, PathData, Point};
|
||||
|
||||
use crate::ps;
|
||||
|
||||
/// What the text document holds of type, and what it leaves out.
|
||||
pub(in crate::ai_editing) const TYPE_ATTRIBUTES: &str = "attributes of type other than its font, size, leading, tracking, baseline shift, horizontal and vertical scale, solid fill, solid stroke and stroke weight, capitals, small capitals at the document's size, Tate-chu-yoko, Standard Vertical Roman Alignment, paragraph alignment, indents, space before and after, hyphenation on or off and right-to-left direction (for example underline, kerning, OpenType features, swatch links of its colors, character and paragraph styles, tabs, lists, optical margin alignment, area type's inset spacing, vertical alignment and flow, a minimum first baseline with the Ascent first baseline, and type on a path's effect, alignment and spacing and an end bracket at the very end of an open path) are left out of the editing data for other apps";
|
||||
pub(in crate::ai_editing) const TYPE_ATTRIBUTES: &str = "attributes of type other than its font, size, leading, tracking, baseline shift, horizontal and vertical scale, solid fill, solid stroke and its weight, cap, join and miter limit, capitals, small capitals at the document's size, Tate-chu-yoko, Standard Vertical Roman Alignment, paragraph alignment, indents, space before and after, hyphenation on or off and right-to-left direction (for example underline, kerning, OpenType features, swatch links of its colors, character and paragraph styles, tabs, lists, optical margin alignment, area type's inset spacing, vertical alignment and flow, a minimum first baseline with the Ascent first baseline, and type on a path's effect, alignment and spacing and an end bracket at the very end of an open path) are left out of the editing data for other apps";
|
||||
|
||||
/// The version of the text document's form (`/98 << /0 n >>`), as the newest files users sent have it.
|
||||
const STREAM_VERSION: u32 = 14;
|
||||
@@ -347,6 +347,9 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool {
|
||||
fill: unlinked(&s.fill),
|
||||
stroke: unlinked(&s.stroke),
|
||||
stroke_width: if stroked { s.stroke_width } else { CharStyle::default().stroke_width },
|
||||
stroke_cap: if stroked { s.stroke_cap } else { CharStyle::default().stroke_cap },
|
||||
stroke_join: if stroked { s.stroke_join } else { CharStyle::default().stroke_join },
|
||||
stroke_miter_limit: if stroked { miter_limit(s.stroke_miter_limit) } else { CharStyle::default().stroke_miter_limit },
|
||||
all_caps: s.all_caps,
|
||||
small_caps: s.small_caps.filter(|v| *v == default_small_caps),
|
||||
tate_chu_yoko: s.tate_chu_yoko,
|
||||
@@ -357,7 +360,19 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool {
|
||||
};
|
||||
let run_lost = |s: &CharStyle| {
|
||||
let stroked = !s.stroke.is_none();
|
||||
let s = CharStyle { stroke_width: if stroked { s.stroke_width } else { CharStyle::default().stroke_width }, ..s.clone() };
|
||||
// Without a stroke, its weight, cap, join and miter limit draw nothing: nothing is lost.
|
||||
let unstroked = CharStyle::default();
|
||||
let s = if stroked {
|
||||
s.clone()
|
||||
} else {
|
||||
CharStyle {
|
||||
stroke_width: unstroked.stroke_width,
|
||||
stroke_cap: unstroked.stroke_cap,
|
||||
stroke_join: unstroked.stroke_join,
|
||||
stroke_miter_limit: unstroked.stroke_miter_limit,
|
||||
..s.clone()
|
||||
}
|
||||
};
|
||||
// A Lab color is written as RGB.
|
||||
let lab = |p: &Paint| matches!(p, Paint::Solid { color: Color::Lab { .. }, .. });
|
||||
s != held_char(&s) || lab(&s.fill) || lab(&s.stroke) || !font_kept(&s)
|
||||
@@ -463,6 +478,32 @@ fn font_name(st: &CharStyle) -> FontRecord {
|
||||
}
|
||||
}
|
||||
|
||||
/// The text document's number for a stroke's cap (key 61), as PostScript numbers them: 0 butt, 1
|
||||
/// round, 2 projecting.
|
||||
fn cap_code(cap: LineCap) -> i64 {
|
||||
match cap {
|
||||
LineCap::Butt => 0,
|
||||
LineCap::Round => 1,
|
||||
LineCap::Square => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// The text document's number for a stroke's join (key 62), as PostScript numbers them: 0 miter, 1
|
||||
/// round, 2 bevel.
|
||||
fn join_code(join: LineJoin) -> i64 {
|
||||
match join {
|
||||
LineJoin::Miter => 0,
|
||||
LineJoin::Round => 1,
|
||||
LineJoin::Bevel => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// A stroke's miter limit (key 64) as the reader takes it back: at least 1 and at most 500, 10 when
|
||||
/// it isn't a number at least 1.
|
||||
fn miter_limit(v: f64) -> f64 {
|
||||
if v.is_finite() && v >= 1.0 { v.min(500.0) } else { 10.0 }
|
||||
}
|
||||
|
||||
/// `v` as the 16.16 fixed-point number a design vector holds (`wght` 700 is 45875200).
|
||||
fn fixed(v: f32) -> i64 {
|
||||
if v.is_finite() { (f64::from(v) * 65536.0).round() as i64 } else { 0 }
|
||||
@@ -621,7 +662,12 @@ impl TextDoc {
|
||||
}
|
||||
d.extend([(56, V::Bool(fill.is_some())), (57, V::Bool(stroke.is_some()))]);
|
||||
if stroke.is_some() {
|
||||
d.push((63, V::Small(st.stroke_width / scale)));
|
||||
d.extend([
|
||||
(61, V::Int(cap_code(st.stroke_cap))),
|
||||
(62, V::Int(join_code(st.stroke_join))),
|
||||
(63, V::Small(st.stroke_width / scale)),
|
||||
(64, real(miter_limit(st.stroke_miter_limit))),
|
||||
]);
|
||||
}
|
||||
dict(d)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,11 @@ pub enum HostError {
|
||||
/// it only while [`crate::Realm::wait_flag`] is set; otherwise the run ends with an internal
|
||||
/// error.
|
||||
Wait,
|
||||
/// `error`, with a mark the host chose. When the run ends with the exception `error` throws,
|
||||
/// or with the limit it ends the run with, [`crate::Realm::error_mark`] gives `mark`. A `catch`
|
||||
/// clause that takes the exception drops the mark, so a `throw` of the caught value later
|
||||
/// raises it without one; a `finally` block that ends normally keeps it.
|
||||
Marked { error: Box<HostError>, mark: u32 },
|
||||
}
|
||||
|
||||
/// A function the host gives a script, with its arguments converted.
|
||||
@@ -450,6 +455,11 @@ impl Interp {
|
||||
HostError::Type(message) => self.throw_host(ErrorType::Type, &message),
|
||||
HostError::Range(message) => self.throw_host(ErrorType::Range, &message),
|
||||
HostError::Syntax(message) => self.throw_host(ErrorType::Syntax, &message),
|
||||
HostError::Marked { error, mark } => {
|
||||
let abrupt = self.host_error(*error)?;
|
||||
self.thrown_mark = Some(mark);
|
||||
Ok(abrupt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -260,6 +260,59 @@ fn errors_from_the_host_can_be_caught_and_an_abort_cannot() {
|
||||
assert_eq!((e.kind, e.message.as_str()), (RunErrorKind::Limit(LimitKind::Host), "stopped by the host"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_marked_host_error_keeps_its_mark_only_while_no_catch_takes_it() {
|
||||
let mut r = realm(1_000_000);
|
||||
let f: HostFunction = Rc::new(|a| {
|
||||
let error = match a.first() {
|
||||
Some(Data::String(k)) if k == "abort" => HostError::Abort("stopped".into()),
|
||||
_ => HostError::Syntax("bad file".into()),
|
||||
};
|
||||
Err(HostError::Marked { error: Box::new(error), mark: 7 })
|
||||
});
|
||||
r.define_host_object("host", vec![("f".into(), HostItem::Function(f))]).unwrap();
|
||||
let cases = [
|
||||
("host.f();", Some(7)),
|
||||
("try { host.f(); } finally { var x = 1; }", Some(7)),
|
||||
("try { try { host.f(); } finally { 1; } } finally { 2; }", Some(7)),
|
||||
("function g() { host.f(); }\ng();", Some(7)),
|
||||
("try { host.f(); } finally { try { try { host.f(); } finally { 1; } } catch (e) {} }", Some(7)),
|
||||
("var saved; try { host.f(); } catch (e) { saved = e; }\nthrow saved;", None),
|
||||
("try { host.f(); } catch (e) { throw e; }", None),
|
||||
("try { try { host.f(); } finally { 1; } } catch (e) { throw e; }", None),
|
||||
("var m; try { host.f(); } catch (e) { m = e.message; }\nthrow new SyntaxError(m);", None),
|
||||
("do { try { host.f(); } finally { break; } } while (false);\nthrow new Error('other');", None),
|
||||
("try { host.f('abort'); } catch (e) { 'caught' }", Some(7)),
|
||||
];
|
||||
for (source, mark) in cases {
|
||||
assert!(r.run(source).is_err(), "{source}");
|
||||
assert_eq!(r.error_mark(), mark, "{source}");
|
||||
}
|
||||
assert_eq!(r.run("1").unwrap(), JsValue::Number(1.0));
|
||||
assert_eq!(r.error_mark(), None, "a later run that ends normally has no mark");
|
||||
}
|
||||
|
||||
/// Marked errors that `finally` blocks discard with `return`, `break` or `continue` are collected
|
||||
/// with the rest of the garbage, so a run that discards many of them stays within its heap.
|
||||
#[test]
|
||||
fn marked_errors_a_finally_block_discards_are_collected() {
|
||||
let mut r = Realm::new();
|
||||
r.set_limits(Limits { fuel: 200_000_000, heap_bytes: 1 << 20, ..Limits::default() });
|
||||
let f: HostFunction = Rc::new(|_| Err(HostError::Marked { error: Box::new(HostError::Syntax("bad file".into())), mark: 7 }));
|
||||
r.define_host_object("host", vec![("f".into(), HostItem::Function(f))]).unwrap();
|
||||
let cases = [
|
||||
"function d() { try { host.f(); } finally { return; } }\nfor (var i = 0; i < 20000; i++) d();",
|
||||
"for (var i = 0; i < 20000; i++) { try { host.f(); } finally { continue; } }",
|
||||
"for (var i = 0; i < 20000; i++) { do { try { host.f(); } finally { break; } } while (false); }",
|
||||
];
|
||||
for source in cases {
|
||||
r.run(source).unwrap_or_else(|e| panic!("{source}: {e:?}"));
|
||||
assert_eq!(r.error_mark(), None, "{source}");
|
||||
}
|
||||
assert!(r.run("try { host.f(); } finally { 1; }").is_err());
|
||||
assert_eq!(r.error_mark(), Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_error_says_where_in_the_source_it_was_raised() {
|
||||
let mut r = realm(1_000_000);
|
||||
|
||||
+38
-1
@@ -204,6 +204,16 @@ pub(crate) struct Interp {
|
||||
pub(crate) evaluated: u32,
|
||||
/// The tag of the source the last run's error was raised in.
|
||||
pub(crate) error_tag: u32,
|
||||
/// The mark of the host error ([`crate::HostError::Marked`]) whose exception or limit is
|
||||
/// propagating.
|
||||
pub(crate) thrown_mark: Option<u32>,
|
||||
/// The marked exceptions `finally` blocks are running for, innermost last, each with its mark,
|
||||
/// which the block's end throws it on with, and the stack slot that holds it while the block
|
||||
/// runs. An entry whose slot no longer holds its exception belongs to a block that `break`,
|
||||
/// `continue` or `return` left, and [`Self::drop_left_finally_marks`] removes it.
|
||||
pub(crate) finally_marks: Vec<(ObjId, u32, usize)>,
|
||||
/// The mark of the host error the last run ended with.
|
||||
pub(crate) error_mark: Option<u32>,
|
||||
/// [`crate::bytecode::Source::strict_writes`] for the scripts the realm runs.
|
||||
pub(crate) strict_writes: bool,
|
||||
}
|
||||
@@ -406,6 +416,9 @@ impl Interp {
|
||||
source_tag: crate::realm::UNKNOWN_SOURCE,
|
||||
evaluated: 0,
|
||||
error_tag: crate::realm::UNKNOWN_SOURCE,
|
||||
thrown_mark: None,
|
||||
finally_marks: Vec::new(),
|
||||
error_mark: None,
|
||||
strict_writes: false,
|
||||
};
|
||||
crate::builtins::install(&mut interp);
|
||||
@@ -560,13 +573,22 @@ impl Interp {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the [`Self::finally_marks`] entries of `finally` blocks that `break`, `continue` or
|
||||
/// `return` left: those whose stack slot no longer holds their exception.
|
||||
fn drop_left_finally_marks(&mut self) {
|
||||
let stack = &self.stack;
|
||||
self.finally_marks.retain(|&(id, _, slot)| matches!(stack.get(slot), Some(Value::Object(o)) if *o == id));
|
||||
}
|
||||
|
||||
pub(crate) fn collect_garbage(&mut self) {
|
||||
self.drop_left_finally_marks();
|
||||
let mut roots = Roots::default();
|
||||
roots.values.extend(self.stack.iter());
|
||||
roots.values.extend(self.temp_roots.iter());
|
||||
roots.values.push(&self.last_thrown);
|
||||
roots.values.push(&self.last_error);
|
||||
roots.values.push(&self.passing);
|
||||
roots.objects.extend(self.finally_marks.iter().map(|&(id, _, _)| id));
|
||||
for f in &self.frames {
|
||||
roots.values.push(&f.this);
|
||||
roots.values.push(&f.completion);
|
||||
@@ -662,6 +684,8 @@ impl Interp {
|
||||
loop {
|
||||
let Some(frame) = self.frames.last_mut() else { return Err(Abrupt::Throw(v)) };
|
||||
if let Some(h) = frame.code.handler(frame.pc.saturating_sub(1)) {
|
||||
// A catch clause starts with `PushCatch`; every other handler is a `finally` block's.
|
||||
let caught = matches!(frame.code.ops.get(h.target as usize), Some(Op::PushCatch(_)));
|
||||
let target_len = frame.base + h.depth as usize;
|
||||
while frame.env_depth > h.env_depth {
|
||||
let outer = self.heap.env(frame.env).and_then(Env::outer);
|
||||
@@ -674,6 +698,10 @@ impl Interp {
|
||||
return internal("handler stack deeper than the stack");
|
||||
}
|
||||
self.stack.truncate(target_len);
|
||||
self.drop_left_finally_marks();
|
||||
if let (Some(mark), false, Value::Object(id)) = (self.thrown_mark.take(), caught, &v) {
|
||||
self.finally_marks.push((*id, mark, target_len));
|
||||
}
|
||||
self.stack.push(v);
|
||||
self.throw_site = None;
|
||||
return Ok(());
|
||||
@@ -794,7 +822,10 @@ impl Interp {
|
||||
Op::Jump(t) => self.jump_to(t),
|
||||
Op::JumpIfFalse(t) | Op::JumpIfTrue(t) => self.op_branch(t, matches!(op, Op::JumpIfTrue(_)), false),
|
||||
Op::JumpIfFalseKeep(t) | Op::JumpIfTrueKeep(t) => self.op_branch(t, matches!(op, Op::JumpIfTrueKeep(_)), true),
|
||||
Op::Throw => Err(Abrupt::Throw(self.pop()?)),
|
||||
Op::Throw => {
|
||||
self.thrown_mark = None;
|
||||
Err(Abrupt::Throw(self.pop()?))
|
||||
}
|
||||
Op::ThrowReferenceError(i) => self.op_throw_reference_error(i),
|
||||
Op::EndFinally(t) => self.op_end_finally(t),
|
||||
Op::NewObject => self.op_new_object(),
|
||||
@@ -878,6 +909,12 @@ 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)
|
||||
{
|
||||
self.thrown_mark = self.finally_marks.get(i).map(|&(_, mark, _)| mark);
|
||||
self.finally_marks.truncate(i);
|
||||
}
|
||||
return Err(Abrupt::Throw(v));
|
||||
}
|
||||
let k = (kind - 2.0) as usize;
|
||||
|
||||
@@ -233,6 +233,9 @@ impl Realm {
|
||||
interp.host_abort = None;
|
||||
interp.waiting = false;
|
||||
interp.error_tag = UNKNOWN_SOURCE;
|
||||
interp.thrown_mark = None;
|
||||
interp.finally_marks.clear();
|
||||
interp.error_mark = None;
|
||||
let strict = interp.options.strict;
|
||||
interp.source_tag = 0;
|
||||
let code = interp.encode_source(source).and_then(|units| interp.compile_source(&units, strict, CodeKind::Global));
|
||||
@@ -253,6 +256,8 @@ impl Realm {
|
||||
let site = interp.throw_site.take();
|
||||
interp.error_tag = site.as_ref().map_or(UNKNOWN_SOURCE, |(code, _)| code.source.tag);
|
||||
let (line, column) = site.map_or((0, 0), |(code, pc)| code.line_column(pc));
|
||||
interp.error_mark = interp.thrown_mark.take().filter(|_| matches!(result, Err(Abrupt::Throw(_) | Abrupt::Limit(LimitKind::Host))));
|
||||
interp.finally_marks.clear();
|
||||
interp.stack.clear();
|
||||
interp.frames.clear();
|
||||
interp.temp_roots.clear();
|
||||
@@ -369,6 +374,12 @@ impl Realm {
|
||||
self.interp.error_tag
|
||||
}
|
||||
|
||||
/// The mark of the host error ([`crate::HostError::Marked`]) the last run ended with, when no
|
||||
/// `catch` clause took that error's exception on its way out; `None` otherwise.
|
||||
pub fn error_mark(&self) -> Option<u32> {
|
||||
self.interp.error_mark
|
||||
}
|
||||
|
||||
/// Fuel the run has left.
|
||||
pub fn fuel_left(&self) -> u64 {
|
||||
self.interp.fuel
|
||||
|
||||
@@ -22,6 +22,10 @@ craft-vectorcraft-format = { workspace = true }
|
||||
sha2 = { version = "0.10", default-features = false }
|
||||
thiserror = { workspace = true }
|
||||
wasmi = { workspace = true }
|
||||
# The monotonic clock of run deadlines and the first-view budget: `std::time::Instant` on the
|
||||
# desktop, the page's `performance.now()` in the web build. It is MIT OR Apache-2.0 and already in
|
||||
# the build through eframe.
|
||||
web-time = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
wat = { workspace = true }
|
||||
|
||||
@@ -86,20 +86,15 @@ pub struct Refreshed {
|
||||
pub failed: usize,
|
||||
}
|
||||
|
||||
/// Milliseconds on a monotonic clock, `None` where there is none (the web build).
|
||||
/// Milliseconds on a monotonic clock, `None` from a clock that cannot be read.
|
||||
pub type Clock = Arc<dyn Fn() -> Option<u64> + Send + Sync>;
|
||||
|
||||
/// Milliseconds since the first reading in this process (native builds); `None` on the web build,
|
||||
/// whose `std` has no monotonic clock.
|
||||
/// Milliseconds since the first reading in this process, from `web_time::Instant`: the system's
|
||||
/// monotonic clock on the desktop and the page's `performance.now()` in the web build.
|
||||
fn monotonic() -> Option<u64> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
|
||||
let start = START.get_or_init(std::time::Instant::now);
|
||||
Some(u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
None
|
||||
static START: std::sync::OnceLock<web_time::Instant> = std::sync::OnceLock::new();
|
||||
let start = START.get_or_init(web_time::Instant::now);
|
||||
Some(u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -579,6 +574,14 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
/// The default clock, which the web build also reads, gives the first view its budget.
|
||||
#[test]
|
||||
fn the_default_clock_gives_the_first_view_a_budget() {
|
||||
let d = Deferrals::new();
|
||||
assert!(matches!(d.allowance(), Allowance::Until { ms: OPENING_BUDGET_MS, .. }));
|
||||
assert!(!d.passed(u64::MAX));
|
||||
}
|
||||
|
||||
/// A stage drawn with several inputs (the paths of a group) holds one record for each, and is
|
||||
/// listed and counted once.
|
||||
#[test]
|
||||
|
||||
@@ -24,8 +24,7 @@ use crate::{Error, Kind, Limits, Plugin, Result, registry};
|
||||
/// document: 200 times a filter's base budget.
|
||||
pub const FUEL: u64 = 10_000_000_000;
|
||||
|
||||
/// Wall-clock budget of one export, in milliseconds (native builds; the web build has no clock,
|
||||
/// and the instruction budget alone bounds it there).
|
||||
/// Wall-clock budget of one export, in milliseconds.
|
||||
pub const WALL_TIME_MS: u64 = 300_000;
|
||||
|
||||
/// Most warnings an export reports.
|
||||
|
||||
+14
-14
@@ -321,29 +321,20 @@ pub struct Report {
|
||||
/// The limit a run past its wall-clock deadline exceeded ([`Error::Limit`]).
|
||||
pub(crate) const TIME_BUDGET: &str = "time budget";
|
||||
|
||||
/// When a run must stop (native: a wall-clock deadline).
|
||||
/// When a run must stop: a deadline on the monotonic clock (`web_time::Instant`, the page's
|
||||
/// `performance.now()` in the web build).
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Deadline {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
at: std::time::Instant,
|
||||
at: web_time::Instant,
|
||||
}
|
||||
|
||||
impl Deadline {
|
||||
pub(crate) fn new(wall_time_ms: u64) -> Self {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = wall_time_ms;
|
||||
Self {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
at: std::time::Instant::now() + std::time::Duration::from_millis(wall_time_ms),
|
||||
}
|
||||
Self { at: web_time::Instant::now() + std::time::Duration::from_millis(wall_time_ms) }
|
||||
}
|
||||
|
||||
pub(crate) fn passed(&self) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if std::time::Instant::now() >= self.at {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
web_time::Instant::now() >= self.at
|
||||
}
|
||||
|
||||
pub(crate) fn check(&self) -> Result<()> {
|
||||
@@ -816,6 +807,15 @@ mod tests {
|
||||
Run::new("test", true, false, Deadline::new(60_000))
|
||||
}
|
||||
|
||||
/// A deadline reads the monotonic clock that the web build also reads: one of 0 ms has
|
||||
/// passed, and one a day away has not.
|
||||
#[test]
|
||||
fn a_deadline_passes_on_the_monotonic_clock() {
|
||||
assert!(Deadline::new(0).passed());
|
||||
assert!(matches!(Deadline::new(0).check(), Err(Error::Limit(l)) if l == TIME_BUDGET));
|
||||
assert!(!Deadline::new(86_400_000).passed());
|
||||
}
|
||||
|
||||
/// Progress is clamped: negative counts are 0 and `done` never passes a known total.
|
||||
#[test]
|
||||
fn progress_is_clamped() {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! permission): no WASI, file system, network, clock or randomness. A module that
|
||||
//! imports anything else is rejected;
|
||||
//! - an **instruction budget** (fuel) per call, metered in slices so a wall-clock deadline is
|
||||
//! also enforced (native builds), so infinite loops end with an error. A filter may ask for a
|
||||
//! also enforced, so infinite loops end with an error. A filter may ask for a
|
||||
//! larger one ([`Budget::Heavy`]), which the host lets the user allow before installing it;
|
||||
//! - a **linear-memory cap**, a **recursion-depth cap** and wasmi's strict module limits;
|
||||
//! - a **fresh instance per run**, so no state leaks between calls; what a plug-in keeps is only
|
||||
|
||||
@@ -29,7 +29,7 @@ const FUEL_COSTS: CustomFuelCosts = CustomFuelCosts { bytes_copied_per_fuel: 64,
|
||||
/// the host learns that only once the run has ended.
|
||||
pub const HEAVY_FUEL: u64 = 1_000_000_000;
|
||||
|
||||
/// Wall-clock budget of a heavy filter's run, in milliseconds (native builds).
|
||||
/// Wall-clock budget of a heavy filter's run, in milliseconds.
|
||||
pub const HEAVY_WALL_TIME_MS: u64 = 300_000;
|
||||
|
||||
/// The limit a run reports when its input and parameters together are past half its memory.
|
||||
@@ -56,8 +56,8 @@ pub struct Limits {
|
||||
pub fuel_per_byte: u64,
|
||||
/// Deepest call nesting.
|
||||
pub max_recursion_depth: usize,
|
||||
/// Wall-clock budget for one run, in milliseconds. Native builds only: the web build has no
|
||||
/// monotonic clock in `std`, so the fuel budget alone bounds it there.
|
||||
/// Wall-clock budget for one run, in milliseconds. The web build measures it with the page's
|
||||
/// `performance.now()`.
|
||||
pub wall_time_ms: u64,
|
||||
/// The most a heavy filter ([`crate::Budget::Heavy`]) is given instead of `fuel_base` and
|
||||
/// `wall_time_ms`: what the host allows, which the module's manifest can ask for but never
|
||||
|
||||
@@ -548,8 +548,8 @@ impl Tool for SmartPenTool {
|
||||
}
|
||||
|
||||
/// A path being drawn: Escape ends it.
|
||||
fn ends_on_escape(&self, _cx: &ToolContext) -> bool {
|
||||
self.path.is_some()
|
||||
fn ends_on_escape(&self, cx: &ToolContext) -> bool {
|
||||
self.active(cx).is_some()
|
||||
}
|
||||
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
|
||||
@@ -782,8 +782,10 @@ impl Tool for GestureTool {
|
||||
};
|
||||
let color = if self.id == "eraser" { [0x80, 0x80, 0x80] } else { FEEDBACK };
|
||||
// A tip that is not round shows the outline of the area it covers, each step of the drag
|
||||
// as wide as the pen pressed there with Size set to Pressure.
|
||||
if matches!(self.id, "eraser" | "blobBrush") && self.roundness < 100.0 && !self.smoothing {
|
||||
// as wide as the pen pressed there with Size set to Pressure. So does a round Blob Brush
|
||||
// tip: a line as wide as the brush would end square, where the stroke it adds is round at
|
||||
// both ends (#4343).
|
||||
if matches!(self.id, "eraser" | "blobBrush") && (self.roundness < 100.0 || self.id == "blobBrush") && !self.smoothing {
|
||||
let area = |pts: &[Point], size| {
|
||||
if self.id == "blobBrush" {
|
||||
blob_area(pts, size, self.angle, self.roundness)
|
||||
|
||||
@@ -382,6 +382,36 @@ fn blob_brush_drag_shows_scrub_length_measurement_labels() {
|
||||
assert!(!quiet.overlays(&off).iter().any(|o| matches!(o, Overlay::Measure { .. })));
|
||||
}
|
||||
|
||||
/// #4343: while it is dragged, a round Blob Brush shows the outline of the area `path.blob` adds,
|
||||
/// which reaches half the brush beyond the ends of the stroke (round tips), not a line as wide as
|
||||
/// the brush that ends square at them.
|
||||
#[test]
|
||||
fn blob_brush_preview_is_the_outline_of_the_area_with_round_tips() {
|
||||
use vectorcraft_geom::kurbo::Shape;
|
||||
let (d, _) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = GestureTool::new("blobBrush");
|
||||
t.size = 20.0;
|
||||
assert_eq!(t.roundness, 100.0, "a round tip by default");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 300.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 300.0));
|
||||
let outlines: Vec<_> =
|
||||
t.overlays(&cx).into_iter().filter_map(|o| if let Overlay::Path { path, width, .. } = o { Some((path, width)) } else { None }).collect();
|
||||
assert!(outlines.iter().all(|(_, width)| f64::from(*width) < t.size / 2.0), "no line as wide as the brush: {outlines:?}");
|
||||
// The stroke's outline is the widest one (the tip at the pointer is another).
|
||||
let Some((path, _)) = outlines.iter().max_by(|a, b| a.0.bounding_box().width().total_cmp(&b.0.bounding_box().width())) else {
|
||||
panic!("no outline")
|
||||
};
|
||||
let b = path.bounding_box();
|
||||
assert!((b.x0 - 40.0).abs() < 0.5 && (b.x1 - 160.0).abs() < 0.5, "round tips reach half the size past the ends: {b:?}");
|
||||
assert!((b.y0 - 290.0).abs() < 0.5 && (b.y1 - 310.0).abs() < 0.5, "{b:?}");
|
||||
// Round, not square: the corner of the box a square-ended line would fill is outside the area.
|
||||
assert!(!path.contains(vectorcraft_geom::Point::new(41.0, 291.0)));
|
||||
assert!(path.contains(vectorcraft_geom::Point::new(45.0, 300.0)));
|
||||
}
|
||||
|
||||
/// The Blob Brush has Keep selected and Merge only with selection, both off by default, and its
|
||||
/// stroke sends both.
|
||||
#[test]
|
||||
|
||||
@@ -243,8 +243,8 @@ impl Tool for PenTool {
|
||||
self.drag.is_some() || self.handle.is_some() || self.convert.is_some()
|
||||
}
|
||||
/// A path being drawn: Escape ends it.
|
||||
fn ends_on_escape(&self, _cx: &ToolContext) -> bool {
|
||||
self.path.is_some()
|
||||
fn ends_on_escape(&self, cx: &ToolContext) -> bool {
|
||||
self.active(cx).is_some()
|
||||
}
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
let tol = cx.tol(5.0).max(cx.point_tol());
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::context::context_items;
|
||||
use super::invoke::click_target;
|
||||
use super::labels::{display_label, dynamic_label, effect_item_label, item_shortcut, spelling_item_label, unlock_item_label};
|
||||
use super::slots::{recent_readable, recent_slot};
|
||||
use super::state::{shortcut_of, shown_state};
|
||||
use super::tree::menu_tree_named;
|
||||
use super::{Entry, Item, Serialize, Value, VectorcraftApp};
|
||||
@@ -133,6 +134,10 @@ fn flatten(app: &VectorcraftApp, path: Vec<String>, items: &[Item], out: &mut Ve
|
||||
if *id == crate::scripts_menu::COMMAND && !crate::scripts_menu::readable(p) {
|
||||
continue;
|
||||
}
|
||||
// The other rows keep their ids: `file.openRecent3` still opens the third file.
|
||||
if id.starts_with("file.openRecent") && recent_slot(app, id).is_some_and(|path| !recent_readable(path)) {
|
||||
continue;
|
||||
}
|
||||
let Some((enabled, _)) = shown_state(app, id, p) else { continue };
|
||||
out.push(MenuEntry {
|
||||
path: path.clone(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::bar::home_key;
|
||||
use super::invoke::menu_dialog;
|
||||
use super::slots::{recent_slot, saved_selection_name, saved_selection_slot, saved_view, saved_view_slot};
|
||||
use super::slots::{recent_readable, recent_slot, saved_selection_name, saved_selection_slot, saved_view, saved_view_slot};
|
||||
use super::state::{checked, selected_image};
|
||||
use super::ui_commands::UI_COMMANDS;
|
||||
use super::{Brightness, Face, SaveMode, Value, VectorcraftApp, io, json, next_zoom};
|
||||
@@ -137,7 +137,7 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
|
||||
"file.exportInBackground" => Ok(io::hide_exports(app)),
|
||||
"file.cancelOpen" => Ok(crate::opening::cancel_all(app)),
|
||||
"file.openInBackground" => Ok(crate::opening::hide_all(app)),
|
||||
id if id.starts_with("file.openRecent") => match recent_slot(app, id).cloned() {
|
||||
id if id.starts_with("file.openRecent") => match recent_slot(app, id).filter(|path| recent_readable(path)).cloned() {
|
||||
Some(path) => io::open_reporting(app, &path),
|
||||
None => Err("no such recent file".into()),
|
||||
},
|
||||
@@ -164,7 +164,7 @@ fn run_ui_part2(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
|
||||
app.ui.recent_files.clear();
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"file.recentFiles" => Ok(json!(io::recent_files(app))),
|
||||
"file.recentFiles" => Ok(json!(io::recent_files(app).iter().filter(|path| recent_readable(path)).collect::<Vec<_>>())),
|
||||
"file.place" => crate::place::run(app, p),
|
||||
"file.place.queue" => crate::place::queue(app, p),
|
||||
"file.export.svg" if p.as_object().is_none_or(|o| o.is_empty()) => {
|
||||
|
||||
@@ -51,6 +51,14 @@ pub(super) fn recent_slot<'a>(app: &'a VectorcraftApp, id: &str) -> Option<&'a S
|
||||
io::recent_files(app).get(n.checked_sub(1)?)
|
||||
}
|
||||
|
||||
/// May the request being served read recent file `path`? Under an automation read root (a control
|
||||
/// client's request), a recent file outside the root is left out of `ui.menu.list` and
|
||||
/// `file.recentFiles`, and its `file.openRecentN` slot answers as an empty one, as a listed script
|
||||
/// outside the root is left out ([`crate::scripts_menu::readable`]).
|
||||
pub(super) fn recent_readable(path: &str) -> bool {
|
||||
vectorcraft_engine::file_access::check_read(path).is_ok()
|
||||
}
|
||||
|
||||
/// View → Perspective Grid → One/Two/Three Point Perspective: the saved-preset slots of each type.
|
||||
pub(super) const PERSPECTIVE_SLOTS: [[&str; crate::dialogs::perspective_presets::SLOTS]; 3] = [
|
||||
[
|
||||
|
||||
@@ -137,7 +137,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"",
|
||||
"{folder?} Type › Find Font's Find in Folder…: asks for a folder (or takes folder), then opens Missing Fonts looking there for the files of the fonts the active document misses (text.findFontFiles). Disabled where there is no folder picker (the web)",
|
||||
),
|
||||
("file.recentFiles", "Recent Files", "", "{} → [path…] most recent first"),
|
||||
("file.recentFiles", "Recent Files", "", "{} → [path…] most recent first; under an automation read root, only the files inside it"),
|
||||
(
|
||||
"file.export.svg",
|
||||
"Export As SVG…",
|
||||
|
||||
@@ -304,6 +304,57 @@ 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).
|
||||
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()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_files_outside_the_read_root_are_not_listed_or_opened() {
|
||||
let (mut app, tx, base) = confined("recent");
|
||||
std::fs::copy(base.join("in/a.svg"), base.join("in/c.svg")).unwrap();
|
||||
let (a, b, c) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg")), s(base.join("in/c.svg")));
|
||||
app.ui.recent_files = vec![a.clone(), b.clone(), c.clone()];
|
||||
let rows = |pairs: &[(&str, &str)]| pairs.iter().map(|(id, l)| (id.to_string(), l.to_string())).collect::<Vec<_>>();
|
||||
|
||||
// The person's menu lists all three.
|
||||
let person = serde_json::to_value(crate::menus::menu_entries(&app)).unwrap();
|
||||
assert_eq!(recent_rows(&person), rows(&[("file.openRecent1", "a.svg"), ("file.openRecent2", "b.svg"), ("file.openRecent3", "c.svg")]));
|
||||
assert_eq!(app.run("file.recentFiles", json!({})).unwrap(), json!([a, b, c]));
|
||||
|
||||
// The client's listing leaves out the second, and the third keeps its id.
|
||||
let r = request(&mut app, &tx, "ui.menu.list", json!({}));
|
||||
assert_eq!(recent_rows(&r["result"]), rows(&[("file.openRecent1", "a.svg"), ("file.openRecent3", "c.svg")]), "{r}");
|
||||
let r = request(&mut app, &tx, "engine.execute", json!({"command": "file.recentFiles"}));
|
||||
assert_eq!(r["result"], json!([a, c]), "{r}");
|
||||
let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "file.openRecent2"}));
|
||||
assert_eq!(r["error"], "no such recent file", "the reply names no path: {r}");
|
||||
assert!(app.session.documents().is_empty());
|
||||
let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "file.openRecent3"}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert_eq!(app.session.documents().len(), 1);
|
||||
assert_eq!(app.ui.recent_files, [c.clone(), a.clone(), b.clone()]);
|
||||
|
||||
// A file outside the root that the person has open is still not brought forward.
|
||||
app.run("file.openRecent3", json!({})).unwrap();
|
||||
assert_eq!((app.session.documents().len(), &app.ui.recent_files[0]), (2, &b));
|
||||
let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "file.openRecent1"}));
|
||||
assert_eq!(r["error"], "no such recent file", "{r}");
|
||||
|
||||
// Rows past Number of Recent Files to Display stay out, and a write root alone lists none.
|
||||
app.session.prefs.recent_files_count = 2;
|
||||
let r = request(&mut app, &tx, "ui.menu.list", json!({}));
|
||||
assert_eq!(recent_rows(&r["result"]), rows(&[("file.openRecent2", "c.svg")]), "{r}");
|
||||
let write_only = AutomationRoots::new(&[], &[&base.join("out")]).unwrap();
|
||||
let listed = vectorcraft_engine::file_access::confine(write_only.as_ref(), || serde_json::to_value(crate::menus::menu_entries(&app)).unwrap());
|
||||
assert!(recent_rows(&listed).is_empty(), "{listed}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// Hidden logic-only ticks between the injection and its UI pass must not drain another step:
|
||||
/// one step per settled pass, not per hidden tick.
|
||||
#[test]
|
||||
|
||||
@@ -535,6 +535,23 @@ fn escape_that_ends_a_path_being_drawn_keeps_the_rotation() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A path that is no longer selected is not being drawn: the next Escape resets the view.
|
||||
#[test]
|
||||
fn escape_after_deselecting_the_path_resets_the_view() {
|
||||
for tool in ["pen", "smartPen", "curvature"] {
|
||||
let mut w = Window::new();
|
||||
w.app.select_tool(tool);
|
||||
let c = w.app.canvas_rect.unwrap().center();
|
||||
w.click(0.0, egui::PointerButton::Primary, c);
|
||||
w.click(0.1, egui::PointerButton::Primary, c + egui::vec2(60.0, 10.0));
|
||||
assert!(w.app.session.tool_ends_on_escape(w.app.view_info()), "{tool}: a path is being drawn");
|
||||
w.app.run("select.none", json!({})).unwrap();
|
||||
assert!(!w.app.session.tool_ends_on_escape(w.app.view_info()), "{tool}: the path is not selected");
|
||||
w.escape(0.2);
|
||||
assert_eq!(w.rotation(), 0.0, "{tool}: one Escape resets the view");
|
||||
}
|
||||
}
|
||||
|
||||
/// In isolation mode, an Escape that ends the Pen's path ends only the path: the next Escape leaves
|
||||
/// isolation mode, and the one after it resets the view.
|
||||
#[test]
|
||||
|
||||
@@ -691,7 +691,8 @@ In the desktop app a Paste from the menus or the keys (`ui.key`) decodes a bitma
|
||||
`edit.paste*` reads it at once.
|
||||
|
||||
File menu details: `file.openRecent1`…`file.openRecent30` open the recent files the `recentFilesCount` preference
|
||||
lists (0 hides them all; `file.recentFiles` returns the listed paths), `file.reveal` shows the document's file in the
|
||||
lists (0 hides them all; `file.recentFiles` returns the listed paths; under a read root, `ui.menu.list` and
|
||||
`file.recentFiles` leave out the files outside it and `file.openRecentN` answers `no such recent file` for one), `file.reveal` shows the document's file in the
|
||||
system file manager (desktop only; answers `{path}`), and `file.newFromTemplate` without params opens a file picker
|
||||
in the Templates folder (preference `templatesFolder`). Older native files open as "<name> [Converted]" (preference
|
||||
`appendConverted`), so their Save asks for a new `.vectorcraft` name. Native files reopen at the view they were saved
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.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: 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: stroked type writes its stroke's cap, join and miter limit into the text document (keys 61, 62 and 64, as `.ai` import reads them since #6500), so they come back when the file opens again and no longer count among the attributes of type left out; a run without a stroke doesn't count them either, as they draw nothing. Tests `ai_editing::tests::type_comes_back_as_type`, `an_unstroked_run_with_a_stroke_style_loses_nothing` (follow-up to #6472) |
|
||||
| 2026-11-11 | minor | `.ai` and EPS import, PDF import, SVG export and `.ai` save: type with a Horizontal Scale other than 100% and tracking or manual kerning is spaced as a `.ai`'s own page spaces it. Layout now measures tracking and manual kerning in thousandths of the em as the scale draws it (see type-parity.md, same date); they were added at the full em, so such type drifted from the page letter by letter. A `.ai`'s page writes that type's character spacing as the tracking itself in a text space as wide as the scale, which PDF scales by it (ISO 32000-1 §9.4.4). In 224 users' files (used locally, never committed), 93 render differently. Of the 25 whose render moved by more than 1% of its pixels against the page, 23 came closer. The note on the type differing from the page shows a smaller share in 5 files (11.0% to 4.4% at most) and a larger one in 2 (by 0.2 to 0.3 points). 9 more envelopes of type open live (113 to 122 of 203, none fewer in any file), and no file switched between its layers and its PDF part. PDF import: the tracking of horizontally scaled type is the file's `Tc` in thousandths of its size (it was that times the scale), the glyphs where they were (test `tests::text_placement::character_spacing_in_horizontally_scaled_type_is_the_files_tc`). SVG export writes the letter spacing in thousandths of the font size it writes (the vertical scale), so type scaled uniformly keeps its spacing in a viewer (test `text_layout::spacing_in_uniformly_scaled_type_takes_the_scale`); tracked type scaled horizontally alone still differs a little there, as before. A `.ai` saved here writes the same values as before; VectorCraft now lays them out as the file's own PDF part draws them. Scores and estimates unchanged. |
|
||||
| 2026-11-11 | minor | `.ai` and EPS import: stroked type in an envelope with Distort Appearance is tried mapped from its glyphs grown by as far as its strokes paint along a side (half the weight of a centred stroke), without what miter joins add at corners, for a stroke on its appearance or on a group round it as well as its characters'; it was mapped from its bounds with strokes, which reach as far as a miter join may at a corner. Tests `ai::envelope::tests::stroked_type_is_tried_mapped_from_its_glyphs_grown_by_half_its_stroke`, `type_with_a_character_stroke_is_tried_mapped_from_its_bounds_with_strokes`, `a_stroke_on_a_group_of_type_grows_the_box_by_half_its_weight` (part of #3179) |
|
||||
| 2026-11-11 | minor | `.ai` and EPS import: a warp of type (an envelope made with a warp around type) whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the warp grid the file saves, with the same drawn-art check, budget and page comparison as #6421: where it passes, the type is editable and keeps the file's shape, and the warp's settings (style, bend) are lost. A second try is only made while more checks are left than there are envelopes of type and warped text objects still to come. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its box with strokes, where the size the file saves for it doesn't already give that box (a rule chosen by measuring real files). An envelope's grid (warp, mesh or top object) now reads its way round from all its rows and columns, not its first row alone: a mesh bent far round (its end columns leaning more than halfway over) reads the way it runs, not turned a quarter round, and a grid symmetric about its middle reads the same wherever it is on the page. Tests `tests_native::a_warp_of_type_opens_as_a_mesh_on_its_saved_grid_and_keeps_its_shape_when_its_text_changes`, `a_warp_of_stroked_type_maps_from_its_bounds_with_strokes_where_its_drawn_art_does`, `a_warp_of_type_without_a_saved_grid_keeps_its_drawn_art`, `a_warp_of_type_tried_again_leaves_the_next_envelope_its_check`, `ai::tests::a_mesh_envelope_bent_far_round_opens_the_way_it_runs`, `a_mesh_envelope_bent_halfway_round_opens_the_same_way_wherever_it_is` (part of #3179) |
|
||||
|
||||
+11
-4
@@ -112,6 +112,9 @@ the suite) keep the agent to the folders you give it:
|
||||
export commands, Export for Screens, Package, Export for Web, print to file, `screenshot {path}`,
|
||||
library and preset saves to a path, the copies `links.relink {copy}` makes.
|
||||
- `file.list {path}` (through `run_command`) lists a folder's files and folders, held to the read roots like any read, so an agent can see what it was given (#1163).
|
||||
- `file.recentFiles` and the control channel's `ui.menu.list` leave out the recent files outside the read roots. The
|
||||
other File › Open Recent Files rows keep their ids (`file.openRecent3` still opens the third file), and
|
||||
`file.openRecentN` for a file outside the roots ends with `no such recent file`, as an empty slot does.
|
||||
- Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any
|
||||
of the write roots written, so one session can read a project folder and a shared assets folder, or write to an
|
||||
output folder and a scratch folder, without widening a root to their common parent. A folder given twice
|
||||
@@ -1614,7 +1617,9 @@ VectorCraft reads them by these rules of its own:
|
||||
Windows device name or stream, and a computer on the network are refused, and so is every path for a script given as
|
||||
`source`. A `JSINCLUDE` folder outside the script's folder is passed over.
|
||||
- A missing or refused included file ends the run before any of it runs. An error in an included file or a file `$.evalFile`
|
||||
ran is reported at that file's line and column, and the report's `file` names the file.
|
||||
ran is reported at that file's line and column, and the report's `file` names the file. So is an error `$.evalFile`
|
||||
throws for a file it cannot run, such as a syntax error, when no `catch` takes it. When a `catch` takes that error
|
||||
and a later `throw` raises it again, the error is reported where that `throw` is, as any other thrown error is.
|
||||
|
||||
`$` also has the other members the Dollar object page lists, besides `$.evalFile`, `$.fileName` and `$.includePath`
|
||||
above. Each run
|
||||
@@ -6243,7 +6248,9 @@ as Overprint Preview shows them (darkened); either way the file has no overprint
|
||||
overprints. `flattenerPreset` (High Resolution, Medium Resolution, Low Resolution or a saved preset,
|
||||
`flattener.presets.list`) flattens transparency before printing, as Object → Flatten Transparency does; empty, a
|
||||
PDF keeps it live (PostScript, which has none, uses `file.print`'s `flattenerPreset`, else this one, else medium).
|
||||
`printAsBitmap` prints each composite page as one image of the art at the document's raster effects resolution.
|
||||
`printAsBitmap` prints each composite page as one image of the art at the document's raster effects resolution,
|
||||
or at the highest resolution that fits when the image would be larger than 64 million pixels or longer than 32,768
|
||||
pixels a side.
|
||||
`color.profile` names an RGB or CMYK profile: composite PDF colours are converted to it with `color.intent` (CMYK
|
||||
colours keep their numbers with `preserveNumbers`), so a CMYK profile writes device CMYK; separations separate with a
|
||||
CMYK one. Settings saved before these options load with their defaults.
|
||||
@@ -7377,8 +7384,8 @@ and the stages with a part of their input too small for the drawing the refresh
|
||||
timed out, failed or have no plug-in. `id` names one object in place of
|
||||
`ids`, and an entry that is not an object id of the document is an error. Each document has its own budget and its own deferred stages. Exports, Expand
|
||||
Appearance and commands run every stage under the per-call limit, so an export never leaves out a stage that was only
|
||||
deferred for time, and the canvas then draws the result. The web build has no clock for the budget: there the first
|
||||
view runs no plug-in effect, and Refresh Deferred Effects runs them. An exporter adds a write-only file format `plugin.<id>` (listed by `document.formats`):
|
||||
deferred for time, and the canvas then draws the result. The web build measures the budget and the per-call
|
||||
limit with the page's `performance.now()`. An exporter adds a write-only file format `plugin.<id>` (listed by `document.formats`):
|
||||
`document.export {format: "plugin.<id>", params?, path?}` writes it, as does a `path` ending in one of its extensions
|
||||
that no built-in format has. An importer adds a read-only file format `plugin.<id>` (listed by `document.formats`
|
||||
with `read: true`, and `openExtensions` holds the extensions File › Open opens with it, without those a built-in format reads or that File › Open installs, imports or loads as something else, such as `.wasm` or `.ase`): `document.open {path | name, dataBase64, importer?:
|
||||
|
||||
+3
-3
@@ -128,7 +128,7 @@ The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssem
|
||||
| Linear memory | 512 MiB per instance (input and parameters: at most half) | `memory.grow` returns -1; oversized input or initial memory fails |
|
||||
| Output | 64 MiB of JSON | `Limit("output size")` |
|
||||
| Instructions (fuel) | 50 M per call + 4,000 per input byte for `vc_run`; a [heavy filter](#heavy-filters) 1 G + 4,000 per byte; an export or import 10 G + 4,000 per byte | `Limit("instruction budget")` |
|
||||
| Wall clock | 60 s per filter run, 5 s per live-effect run, 300 s per heavy filter run, export or import (desktop; checked every 20 M instructions) | `Limit("time budget")` |
|
||||
| Wall clock | 60 s per filter run, 5 s per live-effect run, 300 s per heavy filter run, export or import (checked every 20 M instructions; the web build reads the page's `performance.now()`) | `Limit("time budget")` |
|
||||
| Import input | 128 MiB of the file (`vectorcraft_plugins::import::MAX_INPUT_BYTES`) | the file doesn't open; a larger file named by `path` is not read past that |
|
||||
| Call depth | 1,024 frames | trap |
|
||||
| Module shape | wasmi's strict limits (≤ 10,000 functions, 1 memory, …) | rejected |
|
||||
@@ -147,8 +147,8 @@ refuses or has no plug-in, draws its input art unchanged and is deferred. The st
|
||||
are deferred, and later drawings leave them as they are until their input or parameters change. Effect › Refresh
|
||||
Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget, and `effect.deferredStatus` lists
|
||||
them with the reason (`budget`, `timeout`, `missing`, `failed`, `safeMode`). Each document has its own budget.
|
||||
Exports, Expand Appearance and commands run every stage under the 5 s limit. The web build has no clock for the
|
||||
budget, so there the first view runs no live effect and Refresh Deferred Effects runs them.
|
||||
Exports, Expand Appearance and commands run every stage under the 5 s limit. The web build has the same budget,
|
||||
measured with the page's `performance.now()`.
|
||||
|
||||
### Heavy filters
|
||||
|
||||
|
||||
@@ -733,9 +733,11 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- 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: an error `$.evalFile` throws for a file it cannot run is reported at that file's line and column only while no `catch` has taken it. Caught and thrown again later, in the script or in another file, it is reported where it was thrown again, and a new error whose message holds its text is reported where that error was thrown. VectorCraft's own rule. `crates/engine/src/tests/script_include.rs`, `crates/js/src/host_tests.rs`.
|
||||
- Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, `#strict on` turns on strict write checks (below), and `#targetengine` and `#engine` are refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`).
|
||||
- Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`).
|
||||
- Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens, links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`.
|
||||
- Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`.
|
||||
- Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`.
|
||||
- Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`.
|
||||
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172).
|
||||
@@ -1652,7 +1654,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Filter plug-ins call commands (G17, #2186):** a filter at plug-in API level 2 with the `commands` permission names the commands it may run in its manifest's `commandAllowlist` and runs them with `env.vc_call`, reading each result with `env.vc_call_result`. Each call sees what the calls before it changed; the calls and the filter's output are one undo step, and a run that fails, is canceled or makes a call that fails leaves every document, its selection and its history as they were. Commands that read or write files, use the clipboard, change preferences or the view, run scripts, actions or plug-ins, switch documents or undo are never called, and a manifest that lists one is refused when the module is installed. The install dialog lists the allowed commands. A run makes at most 1,000 calls, and each call costs 10,000 instructions of its budget. Regressions: `crates/plugins/tests/call.rs`, `crates/engine/tests/plugin_call.rs`, `crates/engine/src/cmd/plugin_call.rs`. Scores and hours unchanged.
|
||||
- **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged.
|
||||
- **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged.
|
||||
- **Live-effect plug-ins while a document opens (G17):** the live-effect plug-in runs of a document's first view on screen share a 2,000 ms budget (VectorCraft's own value). A stage left out for time, or one that times out, fails, has no plug-in or meets Safe Mode, draws its input unchanged and is deferred; the status bar says how many, `effect.deferredStatus` lists them with their object, effect and reason, and Effect › Refresh Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget. Each document has its own budget, an edit runs the stages it changes, and exports, Expand Appearance and commands run every stage under the 5 s limit of each run. Regressions: `crates/plugins/src/deferred.rs`, `crates/plugins/src/effect.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/canvas/tests.rs`. Scores and hours unchanged.
|
||||
- **Live-effect plug-ins while a document opens (G17):** the live-effect plug-in runs of a document's first view on screen share a 2,000 ms budget (VectorCraft's own value). A stage left out for time, or one that times out, fails, has no plug-in or meets Safe Mode, draws its input unchanged and is deferred; the status bar says how many, `effect.deferredStatus` lists them with their object, effect and reason, and Effect › Refresh Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget. Each document has its own budget, an edit runs the stages it changes, and exports, Expand Appearance and commands run every stage under the 5 s limit of each run. The web build times the budget and each run's limit with the page's `performance.now()`. Regressions: `crates/plugins/src/deferred.rs`, `crates/plugins/src/effect.rs`, `crates/plugins/src/host.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/canvas/tests.rs`. Scores and hours unchanged.
|
||||
- **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_clear_cache.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged.
|
||||
- **Plug-in settings and cache totals in memory (#6228):** the settings stay in memory while `settings.json` keeps its size and modified time, a set to the same value writes nothing, every other change is written to the file before the call returns, and `cacheBytes` comes from a total kept while the `cache` folder keeps its modified time. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged.
|
||||
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
|
||||
>
|
||||
> Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369).
|
||||
>
|
||||
@@ -158,7 +158,7 @@ tools, never checked side by side) isn't captured row by row.
|
||||
| Anchor Point and Curvature tools' own cursors | done | the caret on the anchor it converts; the pen nib with a curve badge |
|
||||
| Free Transform's Cmd/Ctrl side-shear cursor | done | two arrows passing along the side (ShearH, ShearV), instead of the resize arrow across it |
|
||||
| Cursors of their own for Lasso, Magic Wand, the freehand tools, Shaper, Live Paint Bucket, Measure, Print Tiling, the symbolism tools, vertical type, Scissors (over a segment and away), Mesh and Puppet Warp | partial | each tool returns its own cursor and Use Precise Cursors turns them into the crosshair; until they're drawn they borrow the crosshair, the I-beam, the Pen's add cursor or not-allowed |
|
||||
| Blob Brush and Eraser: the tip's outline at the pointer | done | before a press and while painting, the outline of the tip (its size, angle and roundness, at the zoom) follows the pointer, black on a white halo, round a small centre dot; `[` / `]` and Tool Options change it at once; precise cursors (Caps Lock) show only the crosshair; it goes when the pointer leaves the canvas, while Cmd/Ctrl lends a selection tool and while Alt lends the Blob Brush the Smooth tool (#4977); `crates/tools/src/draw2/gesture.rs` and its tests |
|
||||
| Blob Brush and Eraser: the tip's outline at the pointer | done | before a press and while painting, the outline of the tip (its size, angle and roundness, at the zoom) follows the pointer, black on a white halo, round a small centre dot; `[` / `]` and Tool Options change it at once; precise cursors (Caps Lock) show only the crosshair; it goes when the pointer leaves the canvas, while Cmd/Ctrl lends a selection tool and while Alt lends the Blob Brush the Smooth tool (#4977); `crates/tools/src/draw2/gesture.rs` and its tests While the button is down a round Blob Brush shows the outline of the area it will add, rounded at both ends, not a line as wide as the brush that ends square (#4343). |
|
||||
| Busy cursor during long operations | done | Background open, save and export show the system progress cursor (gap G1) |
|
||||
| Each anchor snaps to Smart Guides and construction angles | done | `DrawSnap` |
|
||||
| Curvature tool: click/drag points, double-click or Alt-click for a corner, temporary Direct Selection, point conversion | partial | circular point markers without direction handles or duplicate markers, Anchor Size and Hide Edges regression-tested (#1236, in part); curve geometry: each stretch of three points follows the circle through them (handles along its tangent, chord/(3·cos²(α/2)) long, shorter in a turn tighter than 30°), open ends curving in, the same rule for the rubber band, dragged, inserted and converted points (#1236, in part); neighbours of a point dragged on an existing path keep their tangents, and modifiers requested in #975 remain |
|
||||
@@ -549,6 +549,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
|
||||
| 2026-11-01 | minor | Arrange Tools add-on (a VectorCraft addition): Randomize Opacity (#4925); scores and estimates unchanged |
|
||||
| 2026-11-01 | minor | Arrange Tools add-on (a VectorCraft addition): Sort Objects (#4917); scores and estimates unchanged |
|
||||
| 2026-11-01 | minor | Blob Brush and Eraser: the tip's outline (size, angle, roundness, at the zoom) follows the pointer before and while painting, around a small centre dot, updating at once with `[` / `]` and Tool Options; precise cursors (Caps Lock) show only the crosshair (#4977); scores and estimates unchanged (estimated) |
|
||||
| 2026-11-11 | minor | Blob Brush: while painting, the preview of a round tip is the outline of the area the stroke adds, round at both ends, as the stroke it commits (#4343). Scores unchanged (estimated). |
|
||||
| 2026-11-01 | minor | Layers panel: an open blend lists its spine as a `<Path>` row below its key objects, and a click selects the blend so Direct Selection edits the spine (#4820); scores and estimates unchanged |
|
||||
| 2026-11-01 | minor | Pen with Cmd/Ctrl held: the lent Selection tool no longer scales the path being drawn by bounding-box handles the canvas hides while its end anchor is selected (a flat path did nothing under a resize pointer); a Cmd drag moves the path instead, and with Direct Selection moves anchors and segments, one undo step each (#4980); scores and estimates unchanged |
|
||||
| 2026-11-01 | minor | Dynamic symbols (#4938): Symbol Options › Symbol Type; per-instance appearance for objects of a dynamic symbol's art; Reset in the Properties panel and the Symbols panel menu; on-canvas Direct Selection of the parts remains |
|
||||
|
||||
Reference in New Issue
Block a user