mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 01:14:29 -06:00
Merge remote-tracking branch 'upstream/main' into fix/4343-blob-brush-round-tips-preview
# Conflicts: # docs/ui-parity.md
This commit is contained in:
@@ -478,6 +478,32 @@ fn an_opened_file_measures_its_type_in_its_own_composite_fonts() {
|
||||
assert_eq!(actual.cached_bounds, Some(expected));
|
||||
}
|
||||
|
||||
/// Threaded type read from a file flows through its frames in the file's own composite fonts, when
|
||||
/// no open document defines them.
|
||||
#[test]
|
||||
fn an_opened_file_flows_its_threads_in_its_own_composite_fonts() {
|
||||
let mut s = session();
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Threaded")]})).unwrap();
|
||||
let story = "The quick brown fox jumps over the lazy dog and keeps running. ".repeat(4);
|
||||
let a = s
|
||||
.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "size": 20, "font": "CF Threaded", "area": {"width": 150, "height": 60}}))
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 300})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
let first = text(&s, a).plain_text();
|
||||
assert!(!first.is_empty() && first.len() < story.len());
|
||||
let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
|
||||
// A new front document without the font: the font database no longer holds it.
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
assert!(vectorcraft_text::FontDb::global().composite_font("CF Threaded").is_none());
|
||||
let file = crate::cmd::fileio::read_for_open("threaded.vectorcraft", &bytes, None, &json!({}), false, false).unwrap();
|
||||
crate::cmd::fileio::open_read(&mut s, file).unwrap();
|
||||
assert_eq!(text(&s, a).plain_text(), first);
|
||||
}
|
||||
|
||||
/// A document replaced while in the back (File › Revert) is measured in its own composite fonts
|
||||
/// when it comes to the front, not in the front document's.
|
||||
#[test]
|
||||
|
||||
@@ -506,7 +506,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
// document keeps its text as saved), and every frame of a thread takes its first frame's
|
||||
// Optical Margin Alignment setting and writing direction. When that changes the direction of a
|
||||
// frame of a restored document, the story flows through that frame's thread again. Threaded
|
||||
// frames keep their size: Auto Size is off for them.
|
||||
// frames keep their size: Auto Size is off for them. The story is laid out in the document's
|
||||
// own composite fonts.
|
||||
let fonts = if doc.text_threads.is_empty() { Default::default() } else { doc.composite_fonts.clone() };
|
||||
let lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&fonts);
|
||||
if !doc.text_threads.is_empty() && !restored {
|
||||
crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc);
|
||||
} else if !doc.text_threads.is_empty() {
|
||||
@@ -517,6 +520,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
// paragraph's first-line indent and list marker.
|
||||
crate::cmd::threads::mark_continued(&mut doc);
|
||||
}
|
||||
drop(lent);
|
||||
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
|
||||
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays);
|
||||
}
|
||||
|
||||
@@ -112,10 +112,12 @@ impl Gen<'_> {
|
||||
let start = if middle { Point::new(at.x, at.y - half) } else { at };
|
||||
let mut t = self.text_object(role, start, &s.replace('|', "\n"), justify);
|
||||
// Kept leading or paragraph spacing moves the lines: put the middle of the first and last baselines on `at`. An
|
||||
// empty first or last line has no baseline, and such a label stays where the default leading puts it.
|
||||
// empty line has no baseline: an empty first or last line leaves the label where the default leading puts it.
|
||||
if middle && kept_para(self.styles, role).is_some() {
|
||||
let ys: Vec<f64> = t.baselines().map(|(a, _)| a.y).collect();
|
||||
if let (Some(first), Some(last), true) = (ys.first(), ys.last(), ys.len() == lines) {
|
||||
let ends_set = s.split('|').next().is_some_and(|l| !l.is_empty()) && s.split('|').next_back().is_some_and(|l| !l.is_empty());
|
||||
let set = s.split('|').filter(|l| !l.is_empty()).count();
|
||||
if let (Some(first), Some(last), true) = (ys.first(), ys.last(), ends_set && ys.len() == set) {
|
||||
t.xf = Affine::translate((0.0, half - (first + last) / 2.0)) * t.xf;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4370,6 +4370,33 @@ fn a_centered_label_with_kept_leading_stays_centered_when_the_graph_is_drawn_aga
|
||||
assert!((after - before).abs() < 0.01, "the two-line category label keeps its middle: {before} {after}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_centered_label_with_an_empty_middle_line_and_kept_leading_stays_centered() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let csv = ",2024,2025\nQ||1,3,2\nQ2,5,4\nQ3,-1,6";
|
||||
let id = NodeId(
|
||||
s.execute("graph.create", &json!({"type": "bar", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
let middle = |s: &Session| {
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let label =
|
||||
group(&n, "Axes").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "Q\n\n1")).unwrap();
|
||||
let b = label.geometric_bounds().unwrap();
|
||||
(b.y0 + b.y1) / 2.0
|
||||
};
|
||||
let before = middle(&s);
|
||||
let l = text_id(&s, id, "Axes", "Q\n\n1");
|
||||
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"autoLeading": 400})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
|
||||
let after = middle(&s);
|
||||
assert!((after - before).abs() < 0.01, "the three-line category label keeps its middle: {before} {after}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_legend_across_top_measures_its_labels_with_system_layout() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -115,7 +115,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rasterize…",
|
||||
["Object"],
|
||||
None,
|
||||
"{ppi?, background?: transparent|white|black|\"#rrggbb\" (#rrggbbaa and #rgba are refused), antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded image (PNG; in a CMYK document with its own colour model, a CMYK image of the art's inks, its transparency as its opacity mask); each defaults to document.rasterEffectsSettings; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}",
|
||||
"{ppi?, background?: transparent|white|black|\"#rrggbb\" (#rrggbbaa and #rgba are refused), antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded image (PNG; in a CMYK document with its own colour model, a CMYK image of the art's inks, its transparency as its opacity mask); each defaults to document.rasterEffectsSettings; an image larger than 64 Mpx or 32,768 px a side is refused; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}",
|
||||
has_selection,
|
||||
rasterize
|
||||
),
|
||||
@@ -670,7 +670,9 @@ fn render_pixels(
|
||||
opts: &vectorcraft_render::RenderOptions,
|
||||
color: RasterColorModel,
|
||||
) -> Result<(vectorcraft_render::Rendered, Option<&'static str>)> {
|
||||
if region.width() * region.height() * scale * scale > MAX_PIXELS || region.width() * scale > 65535.0 || region.height() * scale > 65535.0 {
|
||||
// No side longer than the renderer draws: it would cut the image off there.
|
||||
let side = f64::from(vectorcraft_render::MAX_RASTER_SIDE);
|
||||
if region.width() * region.height() * scale * scale > MAX_PIXELS || region.width() * scale > side || region.height() * scale > side {
|
||||
return Err(EngineError::Other("image would be too large; lower the resolution".into()));
|
||||
}
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,8 +1,11 @@
|
||||
//! A script's preprocessor lines, read before it runs, as the public guide lists them
|
||||
//! (<https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/>):
|
||||
//! `#include` and `//@include` put the text of another file in place of the line, `#includepath`
|
||||
//! sets the folders `#include` looks in, `#script` names the run, and `#target` and `#strict off`
|
||||
//! are accepted. The same lines are read in a file `$.evalFile` runs.
|
||||
//! sets the folders `#include` looks in, `#script` names the run, `#strict on` turns on the checks
|
||||
//! of `$.strict` (<https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/>:
|
||||
//! a write to a read-only property is a runtime error, and objects that take no new properties
|
||||
//! refuse them), and `#target` and `#strict off` are accepted. The same lines are read in a file
|
||||
//! `$.evalFile` runs.
|
||||
//!
|
||||
//! VectorCraft's own rules:
|
||||
//!
|
||||
@@ -13,9 +16,15 @@
|
||||
//! the `JSINCLUDE` environment variable. A later `#includepath` replaces the list.
|
||||
//! - Files are read through the script's [`ScriptScope`]: a refused path ends the run, except a
|
||||
//! `JSINCLUDE` folder outside the scope, which is passed over.
|
||||
//! - `#target` takes any value and changes nothing. `#targetengine`, `#engine` and `#strict on` are
|
||||
//! refused as unsupported, and any other `#name` as unknown; `//@name` with another name stays a
|
||||
//! comment.
|
||||
//! - `#target` takes any value and changes nothing. `#targetengine` and `#engine` are refused as
|
||||
//! unsupported, and any other `#name` as unknown; `//@name` with another name stays a comment.
|
||||
//! - The last `#strict on` or `#strict off` of the script and the files it includes sets the checks
|
||||
//! for the whole run; without one they are off. A write to a property that has only a getter is
|
||||
//! checked as a read-only one, the objects that take no new properties are those that are not
|
||||
//! extensible (`Object.preventExtensions`, `Object.seal`, `Object.freeze`), and the rest of ES5
|
||||
//! strict mode is left to `'use strict'`. Code that `eval` and `Function` compile takes the
|
||||
//! checks of the code that compiles it; a file `$.evalFile` runs takes its own `#strict` line if
|
||||
//! it has one, and otherwise the caller's.
|
||||
//! - A run reads at most [`MAX_DEPTH`] levels of nested files, [`MAX_FILES`] files,
|
||||
//! [`MAX_TOTAL_BYTES`] of source in all (the script's own included) and 8 MiB a file, and looks up
|
||||
//! at most [`MAX_PROBES`] paths. A file may be included again once its earlier inclusion has
|
||||
@@ -97,12 +106,13 @@ pub(crate) struct Failure {
|
||||
pub column: u32,
|
||||
}
|
||||
|
||||
/// A source with its preprocessor lines read: the text to run, where its lines came from, and the
|
||||
/// name its `#script` line gave.
|
||||
/// A source with its preprocessor lines read: the text to run, where its lines came from, the
|
||||
/// name its `#script` line gave, and what its last `#strict` line set (`None` without one).
|
||||
pub(crate) struct Expanded {
|
||||
pub source: String,
|
||||
pub map: SourceMap,
|
||||
pub name: Option<String>,
|
||||
pub strict: Option<bool>,
|
||||
}
|
||||
|
||||
/// A file being run: its name for people and the folder its relative paths start from (none for a
|
||||
@@ -130,13 +140,14 @@ pub(crate) struct Files {
|
||||
}
|
||||
|
||||
/// The output being built: its UTF-16 text, the line it is on, the units since that line began,
|
||||
/// and whether its last unit is a carriage return.
|
||||
/// whether its last unit is a carriage return, and what the last `#strict` line so far set.
|
||||
struct Out {
|
||||
units: Vec<u16>,
|
||||
line: u32,
|
||||
column: u32,
|
||||
after_cr: bool,
|
||||
map: SourceMap,
|
||||
strict: Option<bool>,
|
||||
}
|
||||
|
||||
impl Out {
|
||||
@@ -330,7 +341,7 @@ impl Files {
|
||||
/// `text`, the file `shown` in folder `dir`, with its preprocessor lines read. With `script`,
|
||||
/// its `#script` line names the run.
|
||||
fn expand(&mut self, shown: Arc<str>, dir: Option<PathBuf>, text: &str, script: bool) -> Result<Expanded, Failure> {
|
||||
let mut out = Out { units: Vec::with_capacity(text.len()), line: 1, column: 0, after_cr: false, map: SourceMap::default() };
|
||||
let mut out = Out { units: Vec::with_capacity(text.len()), line: 1, column: 0, after_cr: false, map: SourceMap::default(), strict: None };
|
||||
let mut name = None;
|
||||
self.file(&mut out, shown, dir.as_deref(), text, script, &mut name)?;
|
||||
let source = String::from_utf16(&out.units).map_err(|_| Failure {
|
||||
@@ -341,7 +352,7 @@ impl Files {
|
||||
line: 0,
|
||||
column: 0,
|
||||
})?;
|
||||
Ok(Expanded { source, map: out.map, name })
|
||||
Ok(Expanded { source, map: out.map, name, strict: out.strict })
|
||||
}
|
||||
|
||||
/// Adds file `shown` (in folder `dir`, with text `text`) to `out`, its included files in place
|
||||
@@ -414,8 +425,8 @@ impl Files {
|
||||
}
|
||||
}
|
||||
Some(DirectiveName::Strict) => match value.to_ascii_lowercase().as_str() {
|
||||
"off" => {}
|
||||
"on" => return Err(at(FailKind::Syntax, format!("unsupported strict checking: {label} on"))),
|
||||
"off" => out.strict = Some(false),
|
||||
"on" => out.strict = Some(true),
|
||||
_ => return Err(at(FailKind::Syntax, format!("{label} takes on or off"))),
|
||||
},
|
||||
Some(DirectiveName::TargetEngine) => return Err(at(FailKind::Syntax, format!("unsupported script directive: {label}"))),
|
||||
|
||||
@@ -499,6 +499,8 @@ struct Host {
|
||||
/// 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>>,
|
||||
/// What the last `#strict` line of the file `$.evalFile` last gave set (`None` without one).
|
||||
eval_strict: Cell<Option<bool>>,
|
||||
}
|
||||
|
||||
/// An error `$.evalFile` raised, as thrown, and the file, line and column it is at.
|
||||
@@ -957,7 +959,12 @@ fn dollar_object(host: &Rc<Host>) -> Vec<(String, HostItem)> {
|
||||
let eval_file = {
|
||||
let h = host.clone();
|
||||
let end = host.clone();
|
||||
HostItem::Evaluator { source: function(move |a| eval_file(&h, a)), end: Rc::new(move || end.files.borrow_mut().end_eval()) }
|
||||
let strict = host.clone();
|
||||
HostItem::Evaluator {
|
||||
source: function(move |a| eval_file(&h, a)),
|
||||
end: Rc::new(move || end.files.borrow_mut().end_eval()),
|
||||
strict_writes: Rc::new(move || strict.eval_strict.get()),
|
||||
}
|
||||
};
|
||||
vec![
|
||||
("write".into(), writer(false)),
|
||||
@@ -1006,6 +1013,7 @@ fn eval_file(host: &Host, args: Vec<Data>) -> std::result::Result<Data, HostErro
|
||||
return Err(HostError::Syntax(message));
|
||||
}
|
||||
host.eval_maps.borrow_mut().push(expanded.map);
|
||||
host.eval_strict.set(expanded.strict);
|
||||
Ok(Data::String(expanded.source))
|
||||
}
|
||||
|
||||
@@ -1302,6 +1310,7 @@ impl Session {
|
||||
script_map: RefCell::default(),
|
||||
eval_maps: RefCell::default(),
|
||||
eval_failure: RefCell::default(),
|
||||
eval_strict: Cell::new(None),
|
||||
});
|
||||
let args = to_data(&Value::Object(request.args), &DataLimits::default(), 0, &mut (0, 0))
|
||||
.map_err(|place| EngineError::BadParams { cmd: "script.run".into(), msg: format!("`args{place}` is nested more than 64 levels deep") })?;
|
||||
@@ -1311,6 +1320,7 @@ impl Session {
|
||||
Err(f) => Err(preprocessor_error(f)),
|
||||
Ok(expanded) => {
|
||||
*host.script_map.borrow_mut() = expanded.map;
|
||||
realm.set_strict_writes(expanded.strict == Some(true));
|
||||
realm
|
||||
.define_host_object("vc", vc_object(&host, args))
|
||||
.and_then(|()| realm.define_host_object("$", dollar_object(&host)))
|
||||
|
||||
@@ -1461,6 +1461,26 @@ fn rasterize_replaces_selection_with_image() {
|
||||
assert_eq!(selected(&s), vec![img]);
|
||||
}
|
||||
|
||||
/// Rasterize refuses art that would be longer than the renderer draws (32,768 pixels a side), and
|
||||
/// the art stays as it was; at a resolution that fits, the image covers all of it.
|
||||
#[test]
|
||||
fn rasterize_refuses_an_image_longer_than_the_renderer_draws() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, 20.0, 100.0, 5569.0);
|
||||
sel(&mut s, &[a]);
|
||||
let vb = s.doc().unwrap().doc.bounds_of(&[a], true).unwrap();
|
||||
let before = (undo_len(&s), node(&s, a));
|
||||
let e = s.execute("object.rasterize", &json!({"ppi": 600, "padding": 0})).unwrap_err().to_string();
|
||||
assert!(e.contains("too large"), "{e}");
|
||||
assert_eq!((undo_len(&s), node(&s, a)), before);
|
||||
let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 400, "padding": 0})).unwrap());
|
||||
let n = node(&s, img);
|
||||
let NodeKind::Image(im) = &n.kind else { panic!() };
|
||||
assert!(im.height <= vectorcraft_render::MAX_RASTER_SIDE, "{} pixels", im.height);
|
||||
let b = n.geometric_bounds().unwrap();
|
||||
assert!(close(b.y0, vb.y0) && b.y1 >= vb.y1, "{b:?} covers {vb:?}");
|
||||
}
|
||||
|
||||
/// Rasterize refuses a resolution given as text or as another value that is not a number, and the
|
||||
/// selected art stays as it was.
|
||||
#[test]
|
||||
|
||||
@@ -194,7 +194,6 @@ fn script_target_other_app_does_not_route() {
|
||||
("#target\nvc.log(1);", "#target needs a value"),
|
||||
("#targetengine \"session\"", "unsupported script directive: #targetengine"),
|
||||
("#engine \"main\"", "unsupported script directive: #engine"),
|
||||
("#strict on", "unsupported strict checking: #strict on"),
|
||||
("#strict maybe", "#strict takes on or off"),
|
||||
("#frobnicate 1", "unknown script directive: #frobnicate"),
|
||||
("#include \"x.jsxinc", "unterminated quote in #include"),
|
||||
@@ -223,12 +222,134 @@ fn script_name_comes_from_the_script_line() {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// The script's `ro`, with a read-only `x`.
|
||||
const READ_ONLY: &str = "var ro = {};\nObject.defineProperty(ro, 'x', {value: 1});\n";
|
||||
|
||||
#[test]
|
||||
fn script_strict_on_is_refused() {
|
||||
fn strict_on_runs_and_read_only_write_throws() {
|
||||
let mut s = Session::new();
|
||||
let e = s.execute("script.run", &json!({"source": "#strict on\nvc.log(1);"})).unwrap_err().to_string();
|
||||
assert!(e.contains("unsupported strict checking: #strict on"), "{e}");
|
||||
assert_eq!(s.script_report().unwrap()["kind"], "syntax");
|
||||
for head in ["#strict on\n", "//@strict on\n", "#strict ON\n", "#strict off\n#strict on\n"] {
|
||||
let source = format!("{head}{READ_ONLY}vc.log(1);\n ro.x = 2;\nvc.log(2);");
|
||||
let e = s.execute("script.run", &json!({"source": source})).unwrap_err().to_string();
|
||||
assert!(e.contains("TypeError: cannot assign to property 'x': it is read-only"), "{head}: {e}");
|
||||
let report = s.script_report().unwrap();
|
||||
assert_eq!(report["kind"], "thrown", "{head}");
|
||||
assert_eq!(report["log"], json!([1]), "{head}: the script ran up to the write");
|
||||
let line = head.lines().count() as u64 + 4;
|
||||
assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(line), Some(3)), "{head}: {report}");
|
||||
}
|
||||
// The error can be caught, and every kind of assignment is checked.
|
||||
let source = format!(
|
||||
"#strict on\n{READ_ONLY}var out = [];\nfunction t(f) {{ try {{ f(); out.push('wrote'); }} catch (e) {{ out.push(e.name); }} }}\nt(function () {{ ro.x = 2; }});\nt(function () {{ ro['x'] += 1; }});\nt(function () {{ ro.x++; }});\nt(function () {{ undefined = 1; }});\nt(function () {{ [].concat.length = 0; }});\nvc.log(out);\nvc.log(ro.x);"
|
||||
);
|
||||
let r = s.execute("script.run", &json!({"source": source})).unwrap();
|
||||
assert_eq!(r["log"], json!([["TypeError", "TypeError", "TypeError", "TypeError", "TypeError"], 1]));
|
||||
// Without the line, or with `#strict off` last, the write is ignored as before.
|
||||
for head in ["", "#strict off\n", "#strict on\n#strict off\n"] {
|
||||
let r = s.execute("script.run", &json!({"source": format!("{head}{READ_ONLY}ro.x = 2;\nvc.log(ro.x);")})).unwrap();
|
||||
assert_eq!(r["log"], json!([1]), "{head}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_on_rejects_getter_only_and_nonextensible_additions() {
|
||||
let mut s = Session::new();
|
||||
let source = "#strict on\nvar out = [];\nfunction t(f) { try { f(); out.push('wrote'); } catch (e) { out.push(String(e)); } }\n\
|
||||
var g = {get v() { return 1; }}, set = {set v(x) { this.got = x; }};\n\
|
||||
t(function () { g.v = 2; });\n\
|
||||
t(function () { Object.preventExtensions({}).n = 1; });\n\
|
||||
t(function () { Object.seal({a: 1}).n = 1; });\n\
|
||||
t(function () { Object.freeze({a: 1}).a = 2; });\n\
|
||||
t(function () { vc.extra = 1; });\n\
|
||||
var open = {};\nt(function () { open.n = 1; });\nt(function () { set.v = 3; });\nt(function () { Object.seal({a: 1}).a = 2; });\n\
|
||||
vc.log(out);\nvc.log(open.n);\nvc.log(set.got);";
|
||||
let r = s.execute("script.run", &json!({"source": source})).unwrap();
|
||||
assert_eq!(
|
||||
r["log"],
|
||||
json!([
|
||||
[
|
||||
"TypeError: cannot assign to property 'v': it has only a getter",
|
||||
"TypeError: cannot assign to property 'n': the object is not extensible",
|
||||
"TypeError: cannot assign to property 'n': the object is not extensible",
|
||||
"TypeError: cannot assign to property 'a': it is read-only",
|
||||
"TypeError: cannot assign to property 'extra': the object is not extensible",
|
||||
"wrote",
|
||||
"wrote",
|
||||
"wrote"
|
||||
],
|
||||
1,
|
||||
3
|
||||
])
|
||||
);
|
||||
let r = s.execute("script.run", &json!({"source": source.replacen("#strict on", "#strict off", 1)})).unwrap();
|
||||
assert_eq!(r["log"][0], json!(["wrote", "wrote", "wrote", "wrote", "wrote", "wrote", "wrote", "wrote"]), "off, nothing is refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_on_does_not_enable_es5_early_errors() {
|
||||
let mut s = Session::new();
|
||||
let source = "#strict on\nwith ({w: 1}) { vc.log(w); }\nfunction f(a, a) { return a; }\nvc.log(f(1, 2));\nvc.log(010);\nundeclared = 4;\nvc.log(undeclared);\n\
|
||||
vc.log((function () { return typeof this; })());\nvc.log(delete Object.prototype);\nvar eval2 = 5;\nvc.log((function () { arguments = 6; return arguments; })());";
|
||||
let r = s.execute("script.run", &json!({"source": source})).unwrap();
|
||||
assert_eq!(r["log"], json!([1, 2, 8, 4, "object", false, 6]));
|
||||
// 'use strict' still makes the code strict mode code.
|
||||
let e = s.execute("script.run", &json!({"source": "#strict on\n'use strict';\nundeclared2 = 1;"})).unwrap_err().to_string();
|
||||
assert!(e.contains("ReferenceError"), "{e}");
|
||||
let e = s.execute("script.run", &json!({"source": "#strict on\n'use strict';\nwith ({}) {}"})).unwrap_err().to_string();
|
||||
assert_eq!(s.script_report().unwrap()["kind"], "syntax", "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_on_in_include_controls_expanded_run() {
|
||||
let base = folder("strict");
|
||||
write(&base.join("main/on.jsxinc"), "#strict on\nvar inc = 1;");
|
||||
write(&base.join("main/off.jsxinc"), "//@strict off\n");
|
||||
write(&base.join("main/writes.jsxinc"), &format!("{READ_ONLY}\n ro.x = 2;\n"));
|
||||
// An included file's line sets the checks for the script's own code too.
|
||||
let report = failed(&base, &format!("#include \"on.jsxinc\"\n{READ_ONLY}ro.x = 2;"));
|
||||
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
|
||||
assert_eq!(report["line"], 4, "{report}");
|
||||
assert!(report.get("file").is_none(), "{report}");
|
||||
// A write in an included file is reported at its own line.
|
||||
let report = failed(&base, "#strict on\n#include \"writes.jsxinc\"");
|
||||
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
|
||||
assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(4), Some(3)), "{report}");
|
||||
assert!(report["file"].as_str().unwrap().ends_with("writes.jsxinc"), "{report}");
|
||||
// The last line of the expanded run wins, wherever it is.
|
||||
let r = run(&base, &format!("#strict on\n#include \"off.jsxinc\"\n{READ_ONLY}ro.x = 2;\nvc.log(ro.x);")).unwrap();
|
||||
assert_eq!(r["log"], json!([1]));
|
||||
let report = failed(&base, &format!("#include \"off.jsxinc\"\n#strict on\n{READ_ONLY}ro.x = 2;"));
|
||||
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_mode_in_eval_file_is_scoped() {
|
||||
let base = folder("strict-eval");
|
||||
write(
|
||||
&base.join("main/on.jsx"),
|
||||
&format!(
|
||||
"#strict on\n{READ_ONLY}function w(o) {{ o.x = 2; }}\nvar inner;\ntry {{ ro.x = 2; inner = 'wrote'; }} catch (e) {{ inner = e.name; }}\n"
|
||||
),
|
||||
);
|
||||
write(&base.join("main/off.jsx"), &format!("#strict off\n{READ_ONLY}ro.x = 2;\n'off ran';"));
|
||||
write(&base.join("main/plain.jsx"), &format!("{READ_ONLY}ro.x = 2;\n'plain ran';"));
|
||||
// A file's own `#strict on` holds for its code, its functions included, and not the caller's.
|
||||
let r = run(
|
||||
&base,
|
||||
"$.evalFile('on.jsx');\nvar after;\ntry { ro.x = 3; after = 'ignored'; } catch (e) { after = e.name; }\nvar called;\ntry { w(ro); called = 'wrote'; } catch (e) { called = e.name; }\nvc.log([inner, after, called]);",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["log"], json!([["TypeError", "ignored", "TypeError"]]));
|
||||
// A file without the line takes the caller's checks, and its own `#strict off` turns them off.
|
||||
let r = run(&base, "#strict on\nvc.log($.evalFile('off.jsx'));\nvar e1;\ntry { ro.x = 2; } catch (e) { e1 = e.name; }\nvc.log(e1);").unwrap();
|
||||
assert_eq!(r["log"], json!(["off ran", "TypeError"]), "the caller keeps its own checks");
|
||||
let report = failed(&base, "#strict on\n$.evalFile('plain.jsx');");
|
||||
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
|
||||
assert!(report["file"].as_str().unwrap().ends_with("plain.jsx"), "{report}");
|
||||
assert_eq!(report["line"], 3, "{report}");
|
||||
assert_eq!(run(&base, "vc.log($.evalFile('plain.jsx'));").unwrap()["log"], json!(["plain ran"]));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -708,6 +708,14 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The artboard a guide made from a ruler belongs to: with the Artboard tool, its active
|
||||
/// artboard (an artboard guide); with any other tool, none (a canvas guide).
|
||||
pub fn ruler_guide_artboard(&self) -> Option<usize> {
|
||||
let boards = self.active().map_or(0, |d| d.doc.artboards.len());
|
||||
let active = (self.tool.id() == "artboard").then(|| self.tool.options()["active"].as_u64()).flatten();
|
||||
active.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < boards)
|
||||
}
|
||||
|
||||
/// A guide dragged out of a ruler, whatever the tool: a vertical one out of the left ruler, a
|
||||
/// horizontal one out of the top ruler. `ev` is the pointer in document space (a drag, then the
|
||||
/// release), over the canvas or not (`on_canvas`). The guide shows where the pointer is over
|
||||
@@ -722,11 +730,9 @@ impl Session {
|
||||
let g = match self.ruler_guide.take() {
|
||||
Some(g) => Some(g),
|
||||
None => {
|
||||
let boards = self.active().map_or(0, |d| d.doc.artboards.len());
|
||||
let artboard_tool = self.tool.id() == "artboard";
|
||||
let swap = self.prefs.cmd_ruler_drag_artboard_guides && ev.mods.cmd;
|
||||
let active = artboard_tool.then(|| self.tool.options()["active"].as_u64()).flatten();
|
||||
let artboard = active.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < boards);
|
||||
let artboard = self.ruler_guide_artboard();
|
||||
let view_board = view.artboard;
|
||||
self.with_tool_cx(view, |_, cx| {
|
||||
Some(match (swap, artboard_tool) {
|
||||
|
||||
@@ -9,10 +9,11 @@ use vectorcraft_engine::{Session, command_specs};
|
||||
use vectorcraft_testkit::catch_quiet;
|
||||
use vectorcraft_testkit::fixtures::Fixture;
|
||||
use vectorcraft_testkit::invariants::{check_all, check_session, doc_json};
|
||||
use vectorcraft_testkit::strategies::{junk_params, junk_values, param_keys};
|
||||
use vectorcraft_testkit::strategies::{junk_params, junk_values, param_keys, safe_value};
|
||||
|
||||
/// Commands that replace/close the active document or touch the filesystem are fine to call, but a
|
||||
/// few need their paths redirected into a temp dir (handled by `junk_params`).
|
||||
/// few need their file and folder params redirected into a temp dir (`safe_value`, which
|
||||
/// `junk_params` applies).
|
||||
fn safe_path() -> String {
|
||||
vectorcraft_testkit::temp_dir("sweep").join("out.bin").to_string_lossy().to_string()
|
||||
}
|
||||
@@ -230,6 +231,54 @@ fn tool_edit_state(s: &Session) -> ToolEditState {
|
||||
(docs, s.active_index(), s.paint.clone())
|
||||
}
|
||||
|
||||
/// The params the tool-edit sweep sends `c`: junk params, then each documented key turned on and
|
||||
/// off, with a string under a file or folder key replaced by a location inside `path`'s folder.
|
||||
fn tool_edit_cases(c: &vectorcraft_engine::CommandSpec, path: &str) -> Vec<Value> {
|
||||
let mut cases = junk_params(c.params, path);
|
||||
if heavy(c.id) {
|
||||
cases.truncate(12);
|
||||
}
|
||||
for k in param_keys(c.params) {
|
||||
for v in [json!(true), json!(false), json!(1), json!("on")] {
|
||||
cases.push(json!({ k.as_str(): safe_value(&k, &v, path) }));
|
||||
}
|
||||
}
|
||||
// Find Objects selects only with a filter, which one key at a time does not give it.
|
||||
if c.id == "document.find" {
|
||||
cases.extend([json!({"kind": "Path", "select": true}), json!({"name": "no such name", "select": true})]);
|
||||
}
|
||||
cases
|
||||
}
|
||||
|
||||
/// Every string the sweeps send under a key that names a file or folder to write is inside the
|
||||
/// sweep's temp folder, so no run writes into the working directory.
|
||||
#[test]
|
||||
fn sweep_cases_name_files_only_inside_the_temp_folder() {
|
||||
let path = safe_path();
|
||||
let dir = vectorcraft_testkit::temp_dir("sweep");
|
||||
let keys = ["path", "diffPath", "folder", "destinationFolder"];
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut outside = std::collections::BTreeSet::new();
|
||||
for c in command_specs() {
|
||||
let mut cases = junk_params(c.params, &path);
|
||||
cases.extend(tool_edit_cases(c, &path));
|
||||
for p in &cases {
|
||||
let Some(o) = p.as_object() else { continue };
|
||||
for (k, v) in o.iter().filter(|(k, _)| keys.contains(&k.as_str())) {
|
||||
seen.insert(k.clone());
|
||||
if let Some(s) = v.as_str()
|
||||
&& !std::path::Path::new(s).starts_with(&dir)
|
||||
{
|
||||
outside.insert(format!("{} {{\"{k}\": {v}}}", c.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(seen.len(), keys.len(), "the sweeps send only {seen:?}");
|
||||
let outside: Vec<String> = outside.into_iter().collect();
|
||||
assert!(outside.is_empty(), "{} cases name a location outside {}:\n{}", outside.len(), dir.display(), outside.join("\n"));
|
||||
}
|
||||
|
||||
/// Every command that runs at once while the active tool computes an edit on another thread (a
|
||||
/// Shape Builder merge), rather than applying the edit first (`waits_for_tool_edit`), leaves the
|
||||
/// documents' revisions and selections, the active document and the current paint as they were,
|
||||
@@ -241,19 +290,7 @@ fn commands_that_do_not_wait_for_a_tool_edit_change_no_document() {
|
||||
let mut failures = vec![];
|
||||
for fx in [Fixture::Single, Fixture::Multi] {
|
||||
for c in command_specs().iter().filter(|c| !vectorcraft_engine::waits_for_tool_edit(c.id)) {
|
||||
let mut cases = junk_params(c.params, &path);
|
||||
if heavy(c.id) {
|
||||
cases.truncate(12);
|
||||
}
|
||||
for k in param_keys(c.params) {
|
||||
for v in [json!(true), json!(false), json!(1), json!("on")] {
|
||||
cases.push(json!({ k.as_str(): v }));
|
||||
}
|
||||
}
|
||||
// Find Objects selects only with a filter, which one key at a time does not give it.
|
||||
if c.id == "document.find" {
|
||||
cases.extend([json!({"kind": "Path", "select": true}), json!({"name": "no such name", "select": true})]);
|
||||
}
|
||||
let cases = tool_edit_cases(c, &path);
|
||||
for p in &cases {
|
||||
let mut s = fx.session();
|
||||
let before = tool_edit_state(&s);
|
||||
|
||||
@@ -472,7 +472,7 @@ fn function_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsRe
|
||||
Ok(p) => p,
|
||||
Err(e) => return Err(interp.throw(ErrorType::Syntax, &e.message)),
|
||||
};
|
||||
let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: interp.source_tag });
|
||||
let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: interp.source_tag, strict_writes: interp.writes_mode() });
|
||||
let code = match compile_constructed_function(&program, &source) {
|
||||
Ok(Some(code)) => code,
|
||||
Ok(None) => return Err(interp.throw(ErrorType::Syntax, "the parameters or body do not form one function")),
|
||||
|
||||
@@ -173,6 +173,9 @@ pub(crate) struct Source {
|
||||
pub(crate) text: Rc<[u16]>,
|
||||
/// Which source this is, as [`crate::Realm::error_source`] gives it.
|
||||
pub(crate) tag: u32,
|
||||
/// Writes to read-only and getter-only properties, and new properties on objects that are
|
||||
/// not extensible, throw in this source's code even where it is not strict mode code.
|
||||
pub(crate) strict_writes: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -184,7 +184,7 @@ pub(crate) enum Func {
|
||||
Native { f: NativeFn, kind: NativeKind, constructor: bool, name: &'static str },
|
||||
Host { f: Rc<HostFn>, name: Rc<str> },
|
||||
Data { f: DataFn, name: Rc<str>, read: crate::host::ArgRead },
|
||||
Eval { f: DataFn, end: Rc<dyn Fn()>, name: Rc<str> },
|
||||
Eval { f: DataFn, end: Rc<dyn Fn()>, strict_writes: Rc<dyn Fn() -> Option<bool>>, name: Rc<str> },
|
||||
Bound { target: ObjId, this: Value, args: Rc<[Value]> },
|
||||
}
|
||||
|
||||
|
||||
+18
-6
@@ -111,10 +111,12 @@ pub enum HostItem {
|
||||
/// the call gives that code's completion value, and its declarations become globals. A result
|
||||
/// that is not a string is the call's result. `end` is called when the source has run,
|
||||
/// normally or not. Each source given has the next tag of [`crate::Realm::error_source`],
|
||||
/// from 1.
|
||||
/// from 1. `strict_writes`, called after `source`, gives [`crate::Realm::set_strict_writes`]
|
||||
/// for that source alone; with `None` it takes the calling code's.
|
||||
Evaluator {
|
||||
source: HostFunction,
|
||||
end: Rc<dyn Fn()>,
|
||||
strict_writes: Rc<dyn Fn() -> Option<bool>>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -343,17 +345,26 @@ impl Interp {
|
||||
/// Calls evaluator `f` ([`HostItem::Evaluator`]): reads its arguments, runs it, and runs the
|
||||
/// source it gives as global code, as an indirect `eval` does, giving that code's completion
|
||||
/// value. A result that is not a string is given to the script as it is. `end` is called once
|
||||
/// the source has run, normally or not.
|
||||
pub(crate) fn call_evaluator(&mut self, f: &crate::heap::DataFn, end: &Rc<dyn Fn()>, name: &Rc<str>, args: &[Value]) -> JsResult<Value> {
|
||||
/// the source has run, normally or not. `strict_writes` gives the source's
|
||||
/// [`crate::bytecode::Source::strict_writes`], or `None` for the calling code's.
|
||||
pub(crate) fn call_evaluator(
|
||||
&mut self,
|
||||
f: &crate::heap::DataFn,
|
||||
end: &Rc<dyn Fn()>,
|
||||
strict_writes: &Rc<dyn Fn() -> Option<bool>>,
|
||||
name: &Rc<str>,
|
||||
args: &[Value],
|
||||
) -> JsResult<Value> {
|
||||
let data = self.read_args(name, args, &ArgRead::Data)?;
|
||||
let source = match f(data) {
|
||||
Ok(Data::String(source)) => source,
|
||||
Ok(d) => return self.write_data(d, 0, false),
|
||||
Err(e) => return Err(self.host_error(e)?),
|
||||
};
|
||||
let writes = strict_writes().unwrap_or_else(|| self.writes_mode());
|
||||
self.evaluated = self.evaluated.saturating_add(1);
|
||||
let tag = std::mem::replace(&mut self.source_tag, self.evaluated);
|
||||
let code = self.encode_source(&source).and_then(|units| self.compile_source(&units, false, crate::bytecode::CodeKind::Eval));
|
||||
let code = self.encode_source(&source).and_then(|units| self.compile_source_writes(&units, false, writes, crate::bytecode::CodeKind::Eval));
|
||||
self.source_tag = tag;
|
||||
let r = match code {
|
||||
Ok(code) => self.run_global(code, true),
|
||||
@@ -469,9 +480,10 @@ impl Interp {
|
||||
HostItem::Converted(list, f) => {
|
||||
Prop::Data { value: Value::Object(self.host_function(f, &full, ArgRead::Each(list.into()))), flags: ENUMERABLE }
|
||||
}
|
||||
HostItem::Evaluator { source, end } => {
|
||||
HostItem::Evaluator { source, end, strict_writes } => {
|
||||
let proto = self.intrinsics.function_proto;
|
||||
let f = self.heap.alloc_object(Object::new(Class::Function(Func::Eval { f: source, end, name: full.into() }), Some(proto)));
|
||||
let func = Func::Eval { f: source, end, strict_writes, name: full.into() };
|
||||
let f = self.heap.alloc_object(Object::new(Class::Function(func), Some(proto)));
|
||||
Prop::Data { value: Value::Object(f), flags: ENUMERABLE }
|
||||
}
|
||||
};
|
||||
|
||||
+35
-6
@@ -197,6 +197,8 @@ 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,
|
||||
/// [`crate::bytecode::Source::strict_writes`] for the scripts the realm runs.
|
||||
pub(crate) strict_writes: bool,
|
||||
}
|
||||
|
||||
/// The bytes all live [`HeldBytes`] hold, and the bytes dropped holders released that the heap's
|
||||
@@ -394,6 +396,7 @@ impl Interp {
|
||||
source_tag: crate::realm::UNKNOWN_SOURCE,
|
||||
evaluated: 0,
|
||||
error_tag: crate::realm::UNKNOWN_SOURCE,
|
||||
strict_writes: false,
|
||||
};
|
||||
crate::builtins::install(&mut interp);
|
||||
interp.fuel = limits.fuel;
|
||||
@@ -700,6 +703,18 @@ impl Interp {
|
||||
self.frames.last().is_some_and(|f| f.code.strict)
|
||||
}
|
||||
|
||||
/// Does a property write the running code makes throw when it cannot be made? It does in
|
||||
/// strict mode code and in code from a source with [`crate::bytecode::Source::strict_writes`].
|
||||
fn strict_writes(&self) -> bool {
|
||||
self.frames.last().is_some_and(|f| f.code.strict || f.code.source.strict_writes)
|
||||
}
|
||||
|
||||
/// [`crate::bytecode::Source::strict_writes`] for source compiled now: the running code's,
|
||||
/// or the realm's between runs.
|
||||
pub(crate) fn writes_mode(&self) -> bool {
|
||||
self.frames.last().map_or(self.strict_writes, |f| f.code.source.strict_writes)
|
||||
}
|
||||
|
||||
fn exec(&mut self, op: Op) -> JsResult<()> {
|
||||
match op {
|
||||
Op::Undefined => self.push(Value::Undefined),
|
||||
@@ -918,11 +933,14 @@ impl Interp {
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
Resolved::Obj { object, .. } => self.put(object, name.clone(), v, strict),
|
||||
Resolved::Obj { object, .. } => {
|
||||
let throw = self.strict_writes();
|
||||
self.put(object, name.clone(), v, throw)
|
||||
}
|
||||
Resolved::Unresolved if strict => Err(self.not_defined(name)),
|
||||
Resolved::Unresolved => {
|
||||
let global = self.global;
|
||||
self.put(global, name.clone(), v, false)
|
||||
let (global, throw) = (self.global, self.strict_writes());
|
||||
self.put(global, name.clone(), v, throw)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1453,7 +1471,10 @@ impl Interp {
|
||||
fn put_value(&mut self, base: &Value, k: Key, v: Value) -> JsResult<()> {
|
||||
let strict = self.strict();
|
||||
match base {
|
||||
Value::Object(o) => self.put(*o, k, v, strict),
|
||||
Value::Object(o) => {
|
||||
let throw = self.strict_writes();
|
||||
self.put(*o, k, v, throw)
|
||||
}
|
||||
Value::Undefined | Value::Null => {
|
||||
let message = format!(
|
||||
"cannot set property '{}' of {}",
|
||||
@@ -1869,7 +1890,9 @@ impl Interp {
|
||||
// keeps itself reachable.
|
||||
return Ok(Prepared::Done(self.call_data(&f, &name, &args, &read)?));
|
||||
}
|
||||
Func::Eval { f, end, name } => return Ok(Prepared::Done(self.call_evaluator(&f, &end, &name, &args)?)),
|
||||
Func::Eval { f, end, strict_writes, name } => {
|
||||
return Ok(Prepared::Done(self.call_evaluator(&f, &end, &strict_writes, &name, &args)?));
|
||||
}
|
||||
Func::Host { f: host, .. } => {
|
||||
let js_args: Vec<crate::JsValue> = args.iter().map(|v| self.to_js_value(v)).collect();
|
||||
return match host(&js_args) {
|
||||
@@ -2138,11 +2161,17 @@ impl Interp {
|
||||
/// Parses and compiles source for eval code or a script, counting it against the source
|
||||
/// limit.
|
||||
pub(crate) fn compile_source(&mut self, units: &[u16], strict: bool, kind: CodeKind) -> Result<Rc<Code>, CompileError> {
|
||||
let writes = self.writes_mode();
|
||||
self.compile_source_writes(units, strict, writes, kind)
|
||||
}
|
||||
|
||||
/// [`Interp::compile_source`], with [`crate::bytecode::Source::strict_writes`] set to `writes`.
|
||||
pub(crate) fn compile_source_writes(&mut self, units: &[u16], strict: bool, writes: bool, kind: CodeKind) -> Result<Rc<Code>, CompileError> {
|
||||
self.source_units = self.source_units.saturating_add(units.len());
|
||||
let remaining = self.options.max_source_units.saturating_sub(self.source_units.saturating_sub(units.len()));
|
||||
let options = Options { strict, max_source_units: remaining, ..self.options.clone() };
|
||||
let program = crate::parse_utf16(units, &options).map_err(CompileError::Syntax)?;
|
||||
let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: self.source_tag });
|
||||
let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: self.source_tag, strict_writes: writes });
|
||||
compile_program(&program, &source, kind).map_err(|e| match e {
|
||||
CompileFail::Syntax(s) => CompileError::Syntax(s),
|
||||
CompileFail::Internal(m) => CompileError::Internal(m),
|
||||
|
||||
@@ -691,6 +691,61 @@ fn a_strict_realm_runs_scripts_as_strict_mode_code() {
|
||||
assert_eq!(realm.run("(function () { return typeof this; })()"), Ok(JsValue::String("object".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_writes_are_separate_from_es5_strict() {
|
||||
let setup = "var ro = {}; Object.defineProperty(ro, 'x', {value: 1}); \
|
||||
var getter = {get g() { return 1; }}; var shut = Object.preventExtensions({a: 1}); var open = {};";
|
||||
let mut realm = Realm::new();
|
||||
realm.set_strict_writes(true);
|
||||
realm.run(setup).unwrap();
|
||||
for (src, why) in [
|
||||
("ro.x = 2", "cannot assign to property 'x': it is read-only"),
|
||||
("ro['x'] += 1", "cannot assign to property 'x': it is read-only"),
|
||||
("ro.x++", "cannot assign to property 'x': it is read-only"),
|
||||
("getter.g = 2", "cannot assign to property 'g': it has only a getter"),
|
||||
("shut.b = 2", "cannot assign to property 'b': the object is not extensible"),
|
||||
("Object.create(ro).x = 2", "cannot assign to property 'x': it is read-only"),
|
||||
("NaN = 1", "cannot assign to property 'NaN': it is read-only"),
|
||||
("(function () { ro.x = 2; })()", "cannot assign to property 'x': it is read-only"),
|
||||
("eval('ro.x = 2')", "cannot assign to property 'x': it is read-only"),
|
||||
("(0, eval)('ro.x = 2')", "cannot assign to property 'x': it is read-only"),
|
||||
("Function('ro.x = 2')()", "cannot assign to property 'x': it is read-only"),
|
||||
] {
|
||||
let e = realm.run(src).unwrap_err();
|
||||
assert_eq!(e.kind, RunErrorKind::Thrown, "{src}");
|
||||
assert!(e.message.contains("TypeError") && e.message.contains(why), "{src}: {}", e.message);
|
||||
}
|
||||
// Writes that can be made, and the rest of strict mode, are as before.
|
||||
assert_eq!(realm.run("open.n = 1; shut.a = 5; open.n + shut.a"), Ok(JsValue::Number(6.0)));
|
||||
assert_eq!(realm.run("with ({w: 2}) { w } "), Ok(JsValue::Number(2.0)));
|
||||
assert_eq!(realm.run("(function (a, a) { return a; })(1, 2)"), Ok(JsValue::Number(2.0)));
|
||||
assert_eq!(realm.run("010"), Ok(JsValue::Number(8.0)));
|
||||
assert_eq!(realm.run("undeclared = 3; undeclared"), Ok(JsValue::Number(3.0)));
|
||||
assert_eq!(realm.run("(function () { return typeof this; })()"), Ok(JsValue::String("object".into())));
|
||||
assert_eq!(realm.run("delete Object.prototype"), Ok(JsValue::Bool(false)));
|
||||
assert_eq!(realm.run("'abc'.length = 1; 'abc'.extra = 1; 1"), Ok(JsValue::Number(1.0)), "a primitive's properties as in sloppy mode");
|
||||
// 'use strict' keeps all of ES5 strict mode.
|
||||
assert_eq!(realm.run("'use strict'; with ({}) {}").map_err(|e| e.kind), Err(RunErrorKind::Syntax));
|
||||
assert_eq!(realm.run("'use strict'; delete Object.prototype").map_err(|e| e.kind), Err(RunErrorKind::Thrown));
|
||||
// Off, the same writes are ignored, as in sloppy mode code.
|
||||
realm.set_strict_writes(false);
|
||||
assert_eq!(realm.run("ro.x = 2; getter.g = 2; shut.b = 2; NaN = 1; ro.x + getter.g + String(shut.b)"), Ok(JsValue::String("2undefined".into())));
|
||||
// A function compiled with the writes on keeps them when called from code without.
|
||||
let mut realm = Realm::new();
|
||||
realm.set_strict_writes(true);
|
||||
realm.run(&format!("{setup} function w() {{ ro.x = 2; }}")).unwrap();
|
||||
realm.set_strict_writes(false);
|
||||
assert!(realm.run("w()").unwrap_err().message.contains("it is read-only"));
|
||||
assert_eq!(realm.run("ro.x = 3; ro.x"), Ok(JsValue::Number(1.0)));
|
||||
// An undeclared name added to a global object that is not extensible.
|
||||
let mut realm = Realm::new();
|
||||
realm.set_strict_writes(true);
|
||||
let e = realm.run("Object.preventExtensions(this); newName = 1").unwrap_err();
|
||||
assert!(e.message.contains("cannot assign to property 'newName': the object is not extensible"), "{}", e.message);
|
||||
let mut realm = Realm::new();
|
||||
assert_eq!(realm.run("Object.preventExtensions(this); newName = 1; typeof newName"), Ok(JsValue::String("undefined".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_apply_that_throws_while_reading_its_arguments_keeps_nothing_alive() {
|
||||
// Each call reads a fresh array at index 0 before index 1 throws; the arrays must not stay
|
||||
@@ -774,7 +829,7 @@ fn deep_nesting_compiles_on_a_small_stack() {
|
||||
// ---- the verifier ----
|
||||
|
||||
fn code(ops: Vec<Op>) -> Code {
|
||||
let source = Rc::new(Source { lines: crate::LineTable::new(&[]), text: Rc::from(Vec::<u16>::new()), tag: 0 });
|
||||
let source = Rc::new(Source { lines: crate::LineTable::new(&[]), text: Rc::from(Vec::<u16>::new()), tag: 0, strict_writes: false });
|
||||
Code {
|
||||
positions: vec![0; ops.len()],
|
||||
ops,
|
||||
|
||||
@@ -174,6 +174,14 @@ impl Realm {
|
||||
self.unbroken_fuel = fuel;
|
||||
}
|
||||
|
||||
/// With `on`, the scripts run next (and the code they define and evaluate) throw a
|
||||
/// `TypeError` when they assign to a read-only or getter-only property or add a property to
|
||||
/// an object that is not extensible, as strict mode code does, without the rest of strict
|
||||
/// mode. Off by default.
|
||||
pub fn set_strict_writes(&mut self, on: bool) {
|
||||
self.interp.strict_writes = on;
|
||||
}
|
||||
|
||||
pub fn limits(&self) -> &Limits {
|
||||
&self.interp.limits
|
||||
}
|
||||
|
||||
@@ -502,10 +502,26 @@ pub fn param_keys(doc: &str) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Parameter-name keys that name filesystem paths: fuzzing these with arbitrary strings could
|
||||
/// write files anywhere, so callers substitute a temp path.
|
||||
/// Parameter-name keys that name a file or folder a command may write: fuzzing these with
|
||||
/// arbitrary strings could write files anywhere, so callers substitute a temp path.
|
||||
pub fn is_path_key(k: &str) -> bool {
|
||||
k == "path"
|
||||
matches!(k, "path" | "diffPath") || is_folder_key(k)
|
||||
}
|
||||
|
||||
/// Path keys that name a folder a command creates or writes files into.
|
||||
pub fn is_folder_key(k: &str) -> bool {
|
||||
matches!(k, "folder" | "destinationFolder")
|
||||
}
|
||||
|
||||
/// The location to send under the path key `k`: `safe_path` for a file, a folder next to it for
|
||||
/// a folder key.
|
||||
pub fn safe_location(k: &str, safe_path: &str) -> String {
|
||||
if is_folder_key(k) { format!("{safe_path}-folder") } else { safe_path.to_string() }
|
||||
}
|
||||
|
||||
/// `v`, or the temp location for `k` when `k` is a path key and `v` is a string.
|
||||
pub fn safe_value(k: &str, v: &Value, safe_path: &str) -> Value {
|
||||
if is_path_key(k) && v.is_string() { json!(safe_location(k, safe_path)) } else { v.clone() }
|
||||
}
|
||||
|
||||
/// A deterministic list of junk param objects for `doc` (the command's params doc): each
|
||||
@@ -516,7 +532,7 @@ pub fn junk_params(doc: &str, safe_path: &str) -> Vec<Value> {
|
||||
let mut out = vec![Value::Null, json!([]), json!("str"), json!(42), json!({"unknownKey": 1})];
|
||||
for k in &keys {
|
||||
for (i, v) in junk.iter().enumerate() {
|
||||
let v = if is_path_key(k) && v.is_string() { json!(safe_path) } else { v.clone() };
|
||||
let v = safe_value(k, v, safe_path);
|
||||
// Sparse: one key at a time with every junk value, plus a few combos.
|
||||
let mut o = serde_json::Map::new();
|
||||
o.insert(k.clone(), v.clone());
|
||||
@@ -524,7 +540,7 @@ pub fn junk_params(doc: &str, safe_path: &str) -> Vec<Value> {
|
||||
if i % 5 == 0 {
|
||||
let mut all = serde_json::Map::new();
|
||||
for k2 in &keys {
|
||||
let v2 = if is_path_key(k2) { json!(safe_path) } else { v.clone() };
|
||||
let v2 = if is_path_key(k2) { json!(safe_location(k2, safe_path)) } else { v.clone() };
|
||||
all.insert(k2.clone(), v2);
|
||||
}
|
||||
out.push(Value::Object(all));
|
||||
|
||||
@@ -178,7 +178,11 @@ pub(super) fn ruler_rects(full: egui::Rect) -> [egui::Rect; 3] {
|
||||
/// A drag from a ruler onto the canvas makes a guide where the button is released (the engine's
|
||||
/// `Session::ruler_guide`): a horizontal one from the top ruler, a vertical one from the left
|
||||
/// ruler, snapped as a moved guide is (with Shift to the ruler's ticks). Released anywhere else,
|
||||
/// it makes none. The corner drags the rulers' origin or resets it on a double-click.
|
||||
/// it makes none. A double-click on a ruler (both clicks on that ruler) makes a guide at the
|
||||
/// pointer, across the ruler's axis (a vertical one on the top ruler, a horizontal one on the
|
||||
/// left; swapped in a view turned 90°, where the rulers read the other document axis, and the
|
||||
/// nearer axis in between), unsnapped, as one `guide.add` and so one undo step. The corner drags the rulers' origin or resets it on a
|
||||
/// double-click.
|
||||
pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui::Rect, xf: &Xf) -> Option<Point> {
|
||||
let [top, left, corner] = ruler_rects(full);
|
||||
// Right-click on a ruler or the origin box: the document units, to swap between them as
|
||||
@@ -189,6 +193,40 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c
|
||||
// (right). Not focusable (`click_and_drag` is): no key acts on a ruler, so Tab skips it.
|
||||
let resp = ui.interact(r, egui::Id::unique(id), Sense::CLICK | Sense::DRAG);
|
||||
resp.context_menu(|ui| crate::menus::ruler_menu_body(app, ui, &mut unit_clicked));
|
||||
// egui counts a double-click by position and time alone, so a click on the canvas right
|
||||
// at the ruler and one on the ruler would pass for one: the pair counts only when the
|
||||
// ruler's own click before it came within the double-click delay.
|
||||
let last_click = egui::Id::unique(if vertical { "ruler-left-last-click" } else { "ruler-top-last-click" });
|
||||
let now = ui.input(|i| i.time);
|
||||
let first_here =
|
||||
ui.data(|d| d.get_temp::<f64>(last_click)).is_some_and(|t| now - t <= ui.ctx().options(|o| o.input_options.max_double_click_delay));
|
||||
if resp.clicked_by(egui::PointerButton::Primary) {
|
||||
ui.data_mut(|d| d.insert_temp(last_click, now));
|
||||
}
|
||||
if resp.double_clicked_by(egui::PointerButton::Primary)
|
||||
&& first_here
|
||||
&& let Some(p) = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos()))
|
||||
{
|
||||
// The same document point the drag path reads. A ruler reads the canvas's coordinates
|
||||
// turned with the view, so the guide is the one whose line crosses the ruler's axis:
|
||||
// the vertical one when that axis (the screen's x for the top ruler, y for the left,
|
||||
// in document directions) is nearer the document's x, else the horizontal one. At 0°
|
||||
// that is a vertical guide from the top ruler; at 90° a horizontal one; between, the
|
||||
// nearer axis, whose line still runs through the point and never along the ruler.
|
||||
let at = xf.to_doc(p);
|
||||
let (sn, cs) = xf.rot.sin_cos();
|
||||
let axis = if vertical { (sn, cs) } else { (cs, -sn) };
|
||||
let across = axis.0.abs() >= axis.1.abs();
|
||||
let mut params = json!({"vertical": across, "pos": if across { at.x } else { at.y }});
|
||||
if let Some(ab) = app.session.ruler_guide_artboard() {
|
||||
params["artboard"] = json!(ab);
|
||||
}
|
||||
// The new guide shows even if guides were hidden.
|
||||
app.ui.view.guides = true;
|
||||
if let Err(e) = app.run("guide.add", params) {
|
||||
app.status(e.to_string());
|
||||
}
|
||||
}
|
||||
let kind = if resp.drag_stopped() {
|
||||
PointerKind::Up
|
||||
} else if resp.dragged() {
|
||||
|
||||
@@ -11365,6 +11365,151 @@ fn direct_selection_shows_the_bounding_box_while_cmd_is_held() {
|
||||
assert!(app.ui.view.bounding_box, "View › Show Bounding Box is unchanged");
|
||||
}
|
||||
|
||||
/// #5239: a double-click on a ruler adds a guide at the pointer: a vertical one on the top ruler
|
||||
/// at its x, a horizontal one on the left ruler at its y, as one undo step, shown even when
|
||||
/// guides were hidden. The first click of the pair adds nothing.
|
||||
#[test]
|
||||
fn double_clicking_a_ruler_adds_a_guide_at_that_position() {
|
||||
// The ruler to click, the guide's orientation and its position, the pointer at that position.
|
||||
for (top, vertical, pos) in [(true, true, 120.0), (false, false, 80.0)] {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.ui.view.rulers = true;
|
||||
app.ui.view.guides = false;
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let view = app.view_mut().unwrap();
|
||||
view.zoom = 2.0;
|
||||
view.rotation = 0.0;
|
||||
view.center = Point::new(200.0, 150.0);
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let rect = app.canvas_rect.unwrap();
|
||||
let xf = Xf::new(rect, app.view().unwrap());
|
||||
let at = if top {
|
||||
pos2(xf.to_screen(Point::new(pos, 0.0)).x, rect.top() - RULER / 2.0)
|
||||
} else {
|
||||
pos2(rect.left() - RULER / 2.0, xf.to_screen(Point::new(0.0, pos)).y)
|
||||
};
|
||||
let guides = |app: &mut VectorcraftApp| {
|
||||
let list = app.session.execute("guide.list", &json!({})).unwrap();
|
||||
list.as_array().unwrap().iter().map(|g| (g["vertical"].as_bool().unwrap(), g["pos"].as_f64().unwrap())).collect::<Vec<_>>()
|
||||
};
|
||||
let steps = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len();
|
||||
let before = steps(&app);
|
||||
let click = |app: &mut VectorcraftApp| {
|
||||
let button =
|
||||
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||||
frame(app, &ctx, vec![button(true)]);
|
||||
frame(app, &ctx, vec![button(false)]);
|
||||
};
|
||||
click(&mut app);
|
||||
assert_eq!(guides(&mut app), vec![], "a single click adds none");
|
||||
click(&mut app);
|
||||
let got = guides(&mut app);
|
||||
assert_eq!(got.len(), 1, "top ruler: {top}, {got:?}");
|
||||
assert!(got[0].0 == vertical && (got[0].1 - pos).abs() < 0.01, "top ruler: {top}, want vertical {vertical} at {pos}: {got:?}");
|
||||
assert!(app.ui.view.guides, "the new guide is shown");
|
||||
assert_eq!(steps(&app), before + 1, "one undo step");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(guides(&mut app), vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh app with a 400 x 300 document, rulers on, the view at zoom 2 turned `rotation` degrees,
|
||||
/// and its first frame drawn.
|
||||
fn ruler_guide_app(rotation: f64) -> (VectorcraftApp, egui::Context) {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.ui.view.rulers = true;
|
||||
app.ui.view.guides = false;
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let view = app.view_mut().unwrap();
|
||||
view.zoom = 2.0;
|
||||
view.rotation = rotation;
|
||||
view.center = Point::new(200.0, 150.0);
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
(app, ctx)
|
||||
}
|
||||
|
||||
/// One primary click at `at`, as three frames (move, press, release).
|
||||
fn click_at(app: &mut VectorcraftApp, ctx: &egui::Context, at: Pos2) {
|
||||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(app, ctx, vec![egui::Event::PointerMoved(at)]);
|
||||
frame(app, ctx, vec![button(true)]);
|
||||
frame(app, ctx, vec![button(false)]);
|
||||
}
|
||||
|
||||
/// The ruler guides as (vertical, position).
|
||||
fn guide_list(app: &mut VectorcraftApp) -> Vec<(bool, f64)> {
|
||||
let list = app.session.execute("guide.list", &json!({})).unwrap();
|
||||
list.as_array().unwrap().iter().map(|g| (g["vertical"].as_bool().unwrap(), g["pos"].as_f64().unwrap())).collect()
|
||||
}
|
||||
|
||||
/// #5239 review: egui counts a double-click by position and time, not by widget, so a click on
|
||||
/// the canvas right at a ruler followed at once by a click on the ruler is not a double-click on
|
||||
/// the ruler. No guide; two clicks on the ruler still make one.
|
||||
#[test]
|
||||
fn a_canvas_click_then_a_ruler_click_adds_no_guide() {
|
||||
for (top, canvas_first) in [(true, true), (false, true), (true, false), (false, false)] {
|
||||
let (mut app, ctx) = ruler_guide_app(0.0);
|
||||
let rect = app.canvas_rect.unwrap();
|
||||
// Two points less than 6 pt apart, either side of the ruler's inner edge.
|
||||
let (on_canvas, on_ruler) = if top {
|
||||
(pos2(rect.center().x, rect.top() + 1.0), pos2(rect.center().x, rect.top() - 1.0))
|
||||
} else {
|
||||
(pos2(rect.left() + 1.0, rect.center().y), pos2(rect.left() - 1.0, rect.center().y))
|
||||
};
|
||||
click_at(&mut app, &ctx, if canvas_first { on_canvas } else { on_ruler });
|
||||
click_at(&mut app, &ctx, on_ruler);
|
||||
let want = usize::from(!canvas_first);
|
||||
assert_eq!(guide_list(&mut app).len(), want, "top ruler: {top}, first click on the canvas: {canvas_first}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #5239 review: the rulers read the canvas's coordinates turned with the view, so a guide made
|
||||
/// by a double-click runs across the ruler's axis in any view: it passes through the clicked
|
||||
/// point and crosses the ruler's edge at it, never along it. At 90° the top ruler reads the
|
||||
/// document's y, so its guide is horizontal; the left ruler reads x, so its guide is vertical.
|
||||
/// A view turned part of the way takes the document axis nearer to the ruler's.
|
||||
#[test]
|
||||
fn a_ruler_double_click_follows_a_rotated_view() {
|
||||
// Rotation (degrees), top ruler's guide is vertical, left ruler's guide is vertical.
|
||||
for (rotation, top_vertical, left_vertical) in [
|
||||
(0.0, true, false),
|
||||
(90.0, false, true),
|
||||
(-90.0, false, true),
|
||||
(180.0, true, false),
|
||||
(270.0, false, true),
|
||||
(30.0, true, false),
|
||||
(60.0, false, true),
|
||||
] {
|
||||
for top in [true, false] {
|
||||
let (mut app, ctx) = ruler_guide_app(rotation);
|
||||
let rect = app.canvas_rect.unwrap();
|
||||
let xf = Xf::new(rect, app.view().unwrap());
|
||||
let at =
|
||||
if top { pos2(rect.center().x + 37.0, rect.top() - RULER / 2.0) } else { pos2(rect.left() - RULER / 2.0, rect.center().y + 23.0) };
|
||||
click_at(&mut app, &ctx, at);
|
||||
click_at(&mut app, &ctx, at);
|
||||
let got = guide_list(&mut app);
|
||||
let label = format!("rotation {rotation}, top ruler: {top}: {got:?}");
|
||||
assert_eq!(got.len(), 1, "{label}");
|
||||
let (vertical, pos) = got[0];
|
||||
assert_eq!(vertical, if top { top_vertical } else { left_vertical }, "{label}");
|
||||
// Through the clicked point.
|
||||
let doc = xf.to_doc(at);
|
||||
assert!((pos - if vertical { doc.x } else { doc.y }).abs() < 0.01, "{label}: through {doc:?}");
|
||||
// Its direction on screen is not along the ruler: the line crosses the ruler's edge.
|
||||
let along = if vertical { Point::new(doc.x, doc.y + 1.0) } else { Point::new(doc.x + 1.0, doc.y) };
|
||||
let dir = (xf.to_screen(along) - xf.to_screen(doc)).normalized();
|
||||
let across = if top { dir.y.abs() } else { dir.x.abs() };
|
||||
assert!(across > 0.5, "{label}: the guide runs along the ruler ({dir:?})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An edit to a 3D effect renders the part it changed on this thread, measured by the renderer
|
||||
/// that drew the frame shown ([`vectorcraft_render::Renderer::changed`]): it draws the part with
|
||||
/// the 3D art it made to measure it.
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: 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; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; 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; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: 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; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; 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; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -34,6 +34,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. |
|
||||
| 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. |
|
||||
| 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. |
|
||||
| 2026-11-06 | minor | G9: EPS import reads gradients a page paints as abutting strips as one linear gradient each (no seams); an Illustrator EPS whose page drew them that way keeps its layers. Estimate unchanged. |
|
||||
@@ -667,6 +668,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
|
||||
|
||||
- **Done:** Selection artwork drag across visible document tabs (400 ms hover); lossless target copy, source/clipboard preservation and cancellation regressions. Cross-window dragging remains separate.
|
||||
|
||||
- **Done (#5239):** a double-click on a ruler adds a guide at the pointer (vertical on the top ruler, horizontal on the left one, swapped in a view turned 90°; both clicks on that ruler) through `guide.add`, unsnapped and in one undo step; no scored gap or estimate reassessed.
|
||||
- **Done:** custom document ruler origin (#1212): corner drag with preview, double-click reset to the active artboard, relative X/Y inputs and native save/reopen.
|
||||
- **Done:** View › Rulers › Change to Global / Artboard Rulers (`view.globalRulers`, Cmd+Alt+R): artboard rulers, a new document's, count from the active artboard, each artboard with its own zero point; rulers, X/Y fields and guide readouts follow the mode, which is saved with the document. Estimate unchanged.
|
||||
- **Done (#2849):** View › Rulers › Show Video Rulers (`view.videoRulers`): the rulers show and count the active artboard in video pixels from its top-left corner, a pixel as many points wide as the artboard's Video Ruler Pixel Aspect Ratio and one point tall ([public source](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html)); Hide Video Rulers returns them to the General unit and the ruler origin. Shift-snapping a new guide to ruler ticks still uses the General unit's ticks. Estimate unchanged.
|
||||
@@ -685,7 +687,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
|
||||
|
||||
## G6 Automation and scripting
|
||||
|
||||
- **Missing:** the document object model for scripts and script directives (#1183 is
|
||||
- **Missing:** the document object model for scripts and the `#targetengine` and `#engine` script directives (#1183 is
|
||||
open). File › Scripts › Other Script… and `script.run` run JavaScript files over the command registry,
|
||||
File › Scripts lists the scripts of the Scripts folder, and
|
||||
Window › Script Console shows their output, errors and run reports. Actions, Batch, Variables (text, visibility, image and graph), data set CSV import and
|
||||
|
||||
+16
-4
@@ -1595,9 +1595,20 @@ VectorCraft reads them by these rules of its own:
|
||||
folder of the file that includes. A later `#includepath` replaces the list.
|
||||
- `#script "name"` names the run in the report and the Script Console, unless `name` is given. A `#script` line in an
|
||||
included file does not rename the run.
|
||||
- `#target` takes any value and does not change how the script runs. `#strict off` is taken.
|
||||
- `#strict on`, `#targetengine` and `#engine` end the run with an unsupported-directive error, and any other `#name`
|
||||
with an unknown-directive error. A `//@` line with another name is a comment.
|
||||
- `#target` takes any value and does not change how the script runs.
|
||||
- `#strict on` turns on the checks the dollar-object help page gives for `$.strict`: an assignment to a read-only
|
||||
property throws a `TypeError`, and so does adding a property to an object that takes no new ones. By VectorCraft's
|
||||
own rules, an assignment to a property with only a getter is checked as a read-only one, and the objects that take
|
||||
no new properties are those that are not extensible: objects passed to `Object.preventExtensions`, `Object.seal` or
|
||||
`Object.freeze`, and the frozen `vc` and `$` objects. Other objects take new properties as before. The rest of ES5
|
||||
strict mode (`with`, undeclared variables, `this` in a plain call) is unchanged; a script asks for it with
|
||||
`'use strict'`. `#strict off` turns the checks off, and they are off in a script without either line.
|
||||
- The last `#strict` line of the script and the files it includes sets the checks for the whole run. Code that `eval`
|
||||
and `Function` compile takes the checks of the code that compiles it. A file `$.evalFile` runs takes the checks of
|
||||
its own last `#strict` line, for its code and the functions it defines, or the caller's when it has none; the
|
||||
caller's code keeps its own. Any value other than `on` or `off` ends the run with `#strict takes on or off`.
|
||||
- `#targetengine` and `#engine` end the run with an unsupported-directive error, and any other `#name` with an
|
||||
unknown-directive error. A `//@` line with another name is a comment.
|
||||
- A script run from a file reads its included files and the files `$.evalFile` runs only in that file's folder and
|
||||
the folders below it. A path with a `..` name, a path that leads out of the folder through a link or a junction, a
|
||||
Windows device name or stream, and a computer on the network are refused, and so is every path for a script given as
|
||||
@@ -5232,7 +5243,8 @@ fills or strokes carry effects is written as one piece per fill and stroke, so o
|
||||
When an image in the art can't be decoded, `object.rasterize` and `object.cropImage` (without `trim`) make their image
|
||||
without it, and their results carry the `warning` that Flatten Transparency gives. `object.rasterize` takes each frame
|
||||
of threaded text that it rasterizes out of its thread with the text it shows, as `type.createOutlines` does (see
|
||||
Threading text from the in and out ports), and the other frames keep the text they hold.
|
||||
Threading text from the in and out ports), and the other frames keep the text they hold. `object.rasterize`
|
||||
refuses an image larger than 64 million pixels or longer than 32,768 pixels a side, and the art stays as it was.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"document.rasterEffectsSettings","params":{"resolution":"high","background":"white","addAround":36}}}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+4
-1
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; 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).
|
||||
>
|
||||
@@ -51,6 +51,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. |
|
||||
| 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. |
|
||||
| 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. |
|
||||
| 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. |
|
||||
@@ -237,6 +238,7 @@ tools, never checked side by side) isn't captured row by row.
|
||||
| Snapping panel/popover consolidating the snap options (30.0) | partial | Control bar › Snapping provides quick access through existing view commands and prefs.set, including Snap to Glyph and Alignment Guides (#6164); no View › Snapping submenu. Floating-panel parity remains unverified. |
|
||||
| Customisable rotation snap angle (#973) | missing | Illustrator: Constrain Angle and construction angles only |
|
||||
| Ruler guides: drag out, select, move, delete, lock, Make/Release Guides | done | #414, #451; marquee selects unlocked ruler guides |
|
||||
| A double-click on a ruler adds a guide at the pointer: a vertical one on the top ruler, a horizontal one on the left ruler (swapped in a view turned 90°, where the rulers read the other document axis; the nearer axis in between), exactly where the pointer is (no snapping), shown even with guides hidden, one undo step (`guide.add`); both clicks of the pair must be on that ruler, and the first adds nothing. The [public page on rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html) doesn't describe this gesture, so it follows the request in #5239 | done | #5239; an artboard guide with the Artboard tool, as a drag from a ruler makes one; `canvas/tests.rs` `double_clicking_a_ruler_adds_a_guide_at_that_position`, `a_canvas_click_then_a_ruler_click_adds_no_guide`, `a_ruler_double_click_follows_a_rotated_view` |
|
||||
| Escape cancels an existing ruler-guide press, move or copy | done | Selection and Direct/Group Selection; app-input regressions cover preselected guides, document/interaction-start selection, Undo/Redo and saved state, later on-canvas motion/release and temporary borrowing from idle Pen. All eight checks and the Direct Selection Escape control passed in current full CI. [Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area) |
|
||||
| Ruler origin: corner drag with preview, double-click reset to the active artboard, relative X/Y fields, native save/reopen | done | #1212; `document.setRulerOrigin`, `document.resetRulerOrigin`; geometry and command coordinates stay absolute |
|
||||
| Ruler guides as Layers rows: shown, hidden, locked and deleted with their layer; rows dragged to another layer | done | `guide.setLayer`; guides from older files go on a layer |
|
||||
@@ -507,6 +509,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. |
|
||||
| 2026-11-10 | minor | Symbol libraries: Inventory Icons joins Basic Shapes, UI Frames and Map Markers as a fourth built-in library (Sword, Shield, Potion, Key, Gem; no default 9-slice). |
|
||||
| 2026-11-11 | minor | Files that record no units (PDF, EPS or `.ai` without ruler units, raster images, metafiles, Affinity files, importer plug-ins) open and revert in Preferences › Units › General instead of points; native, SVG and DXF files and `.ai`/EPS ruler units are kept (#5820). Scores and estimates unchanged. |
|
||||
| 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. |
|
||||
|
||||
Reference in New Issue
Block a user