Merge remote-tracking branch 'upstream/main' into fix/4343-blob-brush-round-tips-preview

# Conflicts:
#	docs/ui-parity.md
This commit is contained in:
SofiaAFigueredo
2026-11-11 08:58:18 -03:00
26 changed files with 664 additions and 72 deletions
@@ -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]
+5 -1
View File
@@ -506,7 +506,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
// document keeps its text as saved), and every frame of a thread takes its first frame's
// Optical Margin Alignment setting and writing direction. When that changes the direction of a
// frame of a restored document, the story flows through that frame's thread again. Threaded
// frames keep their size: Auto Size is off for them.
// frames keep their size: Auto Size is off for them. The story is laid out in the document's
// own composite fonts.
let fonts = if doc.text_threads.is_empty() { Default::default() } else { doc.composite_fonts.clone() };
let lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&fonts);
if !doc.text_threads.is_empty() && !restored {
crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc);
} else if !doc.text_threads.is_empty() {
@@ -517,6 +520,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
// paragraph's first-line indent and list marker.
crate::cmd::threads::mark_continued(&mut doc);
}
drop(lent);
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays);
}
+4 -2
View File
@@ -112,10 +112,12 @@ impl Gen<'_> {
let start = if middle { Point::new(at.x, at.y - half) } else { at };
let mut t = self.text_object(role, start, &s.replace('|', "\n"), justify);
// Kept leading or paragraph spacing moves the lines: put the middle of the first and last baselines on `at`. An
// empty first or last line has no baseline, and such a label stays where the default leading puts it.
// empty line has no baseline: an empty first or last line leaves the label where the default leading puts it.
if middle && kept_para(self.styles, role).is_some() {
let ys: Vec<f64> = t.baselines().map(|(a, _)| a.y).collect();
if let (Some(first), Some(last), true) = (ys.first(), ys.last(), ys.len() == lines) {
let ends_set = s.split('|').next().is_some_and(|l| !l.is_empty()) && s.split('|').next_back().is_some_and(|l| !l.is_empty());
let set = s.split('|').filter(|l| !l.is_empty()).count();
if let (Some(first), Some(last), true) = (ys.first(), ys.last(), ends_set && ys.len() == set) {
t.xf = Affine::translate((0.0, half - (first + last) / 2.0)) * t.xf;
}
}
+27
View File
@@ -4370,6 +4370,33 @@ fn a_centered_label_with_kept_leading_stays_centered_when_the_graph_is_drawn_aga
assert!((after - before).abs() < 0.01, "the two-line category label keeps its middle: {before} {after}");
}
#[test]
fn a_centered_label_with_an_empty_middle_line_and_kept_leading_stays_centered() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let csv = ",2024,2025\nQ||1,3,2\nQ2,5,4\nQ3,-1,6";
let id = NodeId(
s.execute("graph.create", &json!({"type": "bar", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
.as_u64()
.unwrap(),
);
let middle = |s: &Session| {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let label =
group(&n, "Axes").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "Q\n\n1")).unwrap();
let b = label.geometric_bounds().unwrap();
(b.y0 + b.y1) / 2.0
};
let before = middle(&s);
let l = text_id(&s, id, "Axes", "Q\n\n1");
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
s.execute("text.setFormat", &json!({"autoLeading": 400})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
let after = middle(&s);
assert!((after - before).abs() < 0.01, "the three-line category label keeps its middle: {before} {after}");
}
#[test]
fn a_legend_across_top_measures_its_labels_with_system_layout() {
let mut s = Session::new();
+4 -2
View File
@@ -115,7 +115,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Rasterize…",
["Object"],
None,
"{ppi?, background?: transparent|white|black|\"#rrggbb\" (#rrggbbaa and #rgba are refused), antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded image (PNG; in a CMYK document with its own colour model, a CMYK image of the art's inks, its transparency as its opacity mask); each defaults to document.rasterEffectsSettings; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}",
"{ppi?, background?: transparent|white|black|\"#rrggbb\" (#rrggbbaa and #rgba are refused), antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded image (PNG; in a CMYK document with its own colour model, a CMYK image of the art's inks, its transparency as its opacity mask); each defaults to document.rasterEffectsSettings; an image larger than 64 Mpx or 32,768 px a side is refused; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}",
has_selection,
rasterize
),
@@ -670,7 +670,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
+23 -12
View File
@@ -1,8 +1,11 @@
//! A script's preprocessor lines, read before it runs, as the public guide lists them
//! (<https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/>):
//! `#include` and `//@include` put the text of another file in place of the line, `#includepath`
//! sets the folders `#include` looks in, `#script` names the run, and `#target` and `#strict off`
//! are accepted. The same lines are read in a file `$.evalFile` runs.
//! sets the folders `#include` looks in, `#script` names the run, `#strict on` turns on the checks
//! of `$.strict` (<https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/>:
//! a write to a read-only property is a runtime error, and objects that take no new properties
//! refuse them), and `#target` and `#strict off` are accepted. The same lines are read in a file
//! `$.evalFile` runs.
//!
//! VectorCraft's own rules:
//!
@@ -13,9 +16,15 @@
//! the `JSINCLUDE` environment variable. A later `#includepath` replaces the list.
//! - Files are read through the script's [`ScriptScope`]: a refused path ends the run, except a
//! `JSINCLUDE` folder outside the scope, which is passed over.
//! - `#target` takes any value and changes nothing. `#targetengine`, `#engine` and `#strict on` are
//! refused as unsupported, and any other `#name` as unknown; `//@name` with another name stays a
//! comment.
//! - `#target` takes any value and changes nothing. `#targetengine` and `#engine` are refused as
//! unsupported, and any other `#name` as unknown; `//@name` with another name stays a comment.
//! - The last `#strict on` or `#strict off` of the script and the files it includes sets the checks
//! for the whole run; without one they are off. A write to a property that has only a getter is
//! checked as a read-only one, the objects that take no new properties are those that are not
//! extensible (`Object.preventExtensions`, `Object.seal`, `Object.freeze`), and the rest of ES5
//! strict mode is left to `'use strict'`. Code that `eval` and `Function` compile takes the
//! checks of the code that compiles it; a file `$.evalFile` runs takes its own `#strict` line if
//! it has one, and otherwise the caller's.
//! - A run reads at most [`MAX_DEPTH`] levels of nested files, [`MAX_FILES`] files,
//! [`MAX_TOTAL_BYTES`] of source in all (the script's own included) and 8 MiB a file, and looks up
//! at most [`MAX_PROBES`] paths. A file may be included again once its earlier inclusion has
@@ -97,12 +106,13 @@ pub(crate) struct Failure {
pub column: u32,
}
/// A source with its preprocessor lines read: the text to run, where its lines came from, and the
/// name its `#script` line gave.
/// A source with its preprocessor lines read: the text to run, where its lines came from, the
/// name its `#script` line gave, and what its last `#strict` line set (`None` without one).
pub(crate) struct Expanded {
pub source: String,
pub map: SourceMap,
pub name: Option<String>,
pub strict: Option<bool>,
}
/// A file being run: its name for people and the folder its relative paths start from (none for a
@@ -130,13 +140,14 @@ pub(crate) struct Files {
}
/// The output being built: its UTF-16 text, the line it is on, the units since that line began,
/// and whether its last unit is a carriage return.
/// whether its last unit is a carriage return, and what the last `#strict` line so far set.
struct Out {
units: Vec<u16>,
line: u32,
column: u32,
after_cr: bool,
map: SourceMap,
strict: Option<bool>,
}
impl Out {
@@ -330,7 +341,7 @@ impl Files {
/// `text`, the file `shown` in folder `dir`, with its preprocessor lines read. With `script`,
/// its `#script` line names the run.
fn expand(&mut self, shown: Arc<str>, dir: Option<PathBuf>, text: &str, script: bool) -> Result<Expanded, Failure> {
let mut out = Out { units: Vec::with_capacity(text.len()), line: 1, column: 0, after_cr: false, map: SourceMap::default() };
let mut out = Out { units: Vec::with_capacity(text.len()), line: 1, column: 0, after_cr: false, map: SourceMap::default(), strict: None };
let mut name = None;
self.file(&mut out, shown, dir.as_deref(), text, script, &mut name)?;
let source = String::from_utf16(&out.units).map_err(|_| Failure {
@@ -341,7 +352,7 @@ impl Files {
line: 0,
column: 0,
})?;
Ok(Expanded { source, map: out.map, name })
Ok(Expanded { source, map: out.map, name, strict: out.strict })
}
/// Adds file `shown` (in folder `dir`, with text `text`) to `out`, its included files in place
@@ -414,8 +425,8 @@ impl Files {
}
}
Some(DirectiveName::Strict) => match value.to_ascii_lowercase().as_str() {
"off" => {}
"on" => return Err(at(FailKind::Syntax, format!("unsupported strict checking: {label} on"))),
"off" => out.strict = Some(false),
"on" => out.strict = Some(true),
_ => return Err(at(FailKind::Syntax, format!("{label} takes on or off"))),
},
Some(DirectiveName::TargetEngine) => return Err(at(FailKind::Syntax, format!("unsupported script directive: {label}"))),
+11 -1
View File
@@ -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)))
+20
View File
@@ -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]
+126 -5
View File
@@ -194,7 +194,6 @@ fn script_target_other_app_does_not_route() {
("#target\nvc.log(1);", "#target needs a value"),
("#targetengine \"session\"", "unsupported script directive: #targetengine"),
("#engine \"main\"", "unsupported script directive: #engine"),
("#strict on", "unsupported strict checking: #strict on"),
("#strict maybe", "#strict takes on or off"),
("#frobnicate 1", "unknown script directive: #frobnicate"),
("#include \"x.jsxinc", "unterminated quote in #include"),
@@ -223,12 +222,134 @@ fn script_name_comes_from_the_script_line() {
let _ = std::fs::remove_dir_all(base);
}
/// The script's `ro`, with a read-only `x`.
const READ_ONLY: &str = "var ro = {};\nObject.defineProperty(ro, 'x', {value: 1});\n";
#[test]
fn script_strict_on_is_refused() {
fn strict_on_runs_and_read_only_write_throws() {
let mut s = Session::new();
let e = s.execute("script.run", &json!({"source": "#strict on\nvc.log(1);"})).unwrap_err().to_string();
assert!(e.contains("unsupported strict checking: #strict on"), "{e}");
assert_eq!(s.script_report().unwrap()["kind"], "syntax");
for head in ["#strict on\n", "//@strict on\n", "#strict ON\n", "#strict off\n#strict on\n"] {
let source = format!("{head}{READ_ONLY}vc.log(1);\n ro.x = 2;\nvc.log(2);");
let e = s.execute("script.run", &json!({"source": source})).unwrap_err().to_string();
assert!(e.contains("TypeError: cannot assign to property 'x': it is read-only"), "{head}: {e}");
let report = s.script_report().unwrap();
assert_eq!(report["kind"], "thrown", "{head}");
assert_eq!(report["log"], json!([1]), "{head}: the script ran up to the write");
let line = head.lines().count() as u64 + 4;
assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(line), Some(3)), "{head}: {report}");
}
// The error can be caught, and every kind of assignment is checked.
let source = format!(
"#strict on\n{READ_ONLY}var out = [];\nfunction t(f) {{ try {{ f(); out.push('wrote'); }} catch (e) {{ out.push(e.name); }} }}\nt(function () {{ ro.x = 2; }});\nt(function () {{ ro['x'] += 1; }});\nt(function () {{ ro.x++; }});\nt(function () {{ undefined = 1; }});\nt(function () {{ [].concat.length = 0; }});\nvc.log(out);\nvc.log(ro.x);"
);
let r = s.execute("script.run", &json!({"source": source})).unwrap();
assert_eq!(r["log"], json!([["TypeError", "TypeError", "TypeError", "TypeError", "TypeError"], 1]));
// Without the line, or with `#strict off` last, the write is ignored as before.
for head in ["", "#strict off\n", "#strict on\n#strict off\n"] {
let r = s.execute("script.run", &json!({"source": format!("{head}{READ_ONLY}ro.x = 2;\nvc.log(ro.x);")})).unwrap();
assert_eq!(r["log"], json!([1]), "{head}");
}
}
#[test]
fn strict_on_rejects_getter_only_and_nonextensible_additions() {
let mut s = Session::new();
let source = "#strict on\nvar out = [];\nfunction t(f) { try { f(); out.push('wrote'); } catch (e) { out.push(String(e)); } }\n\
var g = {get v() { return 1; }}, set = {set v(x) { this.got = x; }};\n\
t(function () { g.v = 2; });\n\
t(function () { Object.preventExtensions({}).n = 1; });\n\
t(function () { Object.seal({a: 1}).n = 1; });\n\
t(function () { Object.freeze({a: 1}).a = 2; });\n\
t(function () { vc.extra = 1; });\n\
var open = {};\nt(function () { open.n = 1; });\nt(function () { set.v = 3; });\nt(function () { Object.seal({a: 1}).a = 2; });\n\
vc.log(out);\nvc.log(open.n);\nvc.log(set.got);";
let r = s.execute("script.run", &json!({"source": source})).unwrap();
assert_eq!(
r["log"],
json!([
[
"TypeError: cannot assign to property 'v': it has only a getter",
"TypeError: cannot assign to property 'n': the object is not extensible",
"TypeError: cannot assign to property 'n': the object is not extensible",
"TypeError: cannot assign to property 'a': it is read-only",
"TypeError: cannot assign to property 'extra': the object is not extensible",
"wrote",
"wrote",
"wrote"
],
1,
3
])
);
let r = s.execute("script.run", &json!({"source": source.replacen("#strict on", "#strict off", 1)})).unwrap();
assert_eq!(r["log"][0], json!(["wrote", "wrote", "wrote", "wrote", "wrote", "wrote", "wrote", "wrote"]), "off, nothing is refused");
}
#[test]
fn strict_on_does_not_enable_es5_early_errors() {
let mut s = Session::new();
let source = "#strict on\nwith ({w: 1}) { vc.log(w); }\nfunction f(a, a) { return a; }\nvc.log(f(1, 2));\nvc.log(010);\nundeclared = 4;\nvc.log(undeclared);\n\
vc.log((function () { return typeof this; })());\nvc.log(delete Object.prototype);\nvar eval2 = 5;\nvc.log((function () { arguments = 6; return arguments; })());";
let r = s.execute("script.run", &json!({"source": source})).unwrap();
assert_eq!(r["log"], json!([1, 2, 8, 4, "object", false, 6]));
// 'use strict' still makes the code strict mode code.
let e = s.execute("script.run", &json!({"source": "#strict on\n'use strict';\nundeclared2 = 1;"})).unwrap_err().to_string();
assert!(e.contains("ReferenceError"), "{e}");
let e = s.execute("script.run", &json!({"source": "#strict on\n'use strict';\nwith ({}) {}"})).unwrap_err().to_string();
assert_eq!(s.script_report().unwrap()["kind"], "syntax", "{e}");
}
#[test]
fn strict_on_in_include_controls_expanded_run() {
let base = folder("strict");
write(&base.join("main/on.jsxinc"), "#strict on\nvar inc = 1;");
write(&base.join("main/off.jsxinc"), "//@strict off\n");
write(&base.join("main/writes.jsxinc"), &format!("{READ_ONLY}\n ro.x = 2;\n"));
// An included file's line sets the checks for the script's own code too.
let report = failed(&base, &format!("#include \"on.jsxinc\"\n{READ_ONLY}ro.x = 2;"));
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
assert_eq!(report["line"], 4, "{report}");
assert!(report.get("file").is_none(), "{report}");
// A write in an included file is reported at its own line.
let report = failed(&base, "#strict on\n#include \"writes.jsxinc\"");
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(4), Some(3)), "{report}");
assert!(report["file"].as_str().unwrap().ends_with("writes.jsxinc"), "{report}");
// The last line of the expanded run wins, wherever it is.
let r = run(&base, &format!("#strict on\n#include \"off.jsxinc\"\n{READ_ONLY}ro.x = 2;\nvc.log(ro.x);")).unwrap();
assert_eq!(r["log"], json!([1]));
let report = failed(&base, &format!("#include \"off.jsxinc\"\n#strict on\n{READ_ONLY}ro.x = 2;"));
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn strict_mode_in_eval_file_is_scoped() {
let base = folder("strict-eval");
write(
&base.join("main/on.jsx"),
&format!(
"#strict on\n{READ_ONLY}function w(o) {{ o.x = 2; }}\nvar inner;\ntry {{ ro.x = 2; inner = 'wrote'; }} catch (e) {{ inner = e.name; }}\n"
),
);
write(&base.join("main/off.jsx"), &format!("#strict off\n{READ_ONLY}ro.x = 2;\n'off ran';"));
write(&base.join("main/plain.jsx"), &format!("{READ_ONLY}ro.x = 2;\n'plain ran';"));
// A file's own `#strict on` holds for its code, its functions included, and not the caller's.
let r = run(
&base,
"$.evalFile('on.jsx');\nvar after;\ntry { ro.x = 3; after = 'ignored'; } catch (e) { after = e.name; }\nvar called;\ntry { w(ro); called = 'wrote'; } catch (e) { called = e.name; }\nvc.log([inner, after, called]);",
)
.unwrap();
assert_eq!(r["log"], json!([["TypeError", "ignored", "TypeError"]]));
// A file without the line takes the caller's checks, and its own `#strict off` turns them off.
let r = run(&base, "#strict on\nvc.log($.evalFile('off.jsx'));\nvar e1;\ntry { ro.x = 2; } catch (e) { e1 = e.name; }\nvc.log(e1);").unwrap();
assert_eq!(r["log"], json!(["off ran", "TypeError"]), "the caller keeps its own checks");
let report = failed(&base, "#strict on\n$.evalFile('plain.jsx');");
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
assert!(report["file"].as_str().unwrap().ends_with("plain.jsx"), "{report}");
assert_eq!(report["line"], 3, "{report}");
assert_eq!(run(&base, "vc.log($.evalFile('plain.jsx'));").unwrap()["log"], json!(["plain ran"]));
let _ = std::fs::remove_dir_all(base);
}
#[test]
+9 -3
View File
@@ -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) {
+52 -15
View File
@@ -9,10 +9,11 @@ use vectorcraft_engine::{Session, command_specs};
use vectorcraft_testkit::catch_quiet;
use vectorcraft_testkit::fixtures::Fixture;
use vectorcraft_testkit::invariants::{check_all, check_session, doc_json};
use vectorcraft_testkit::strategies::{junk_params, junk_values, param_keys};
use vectorcraft_testkit::strategies::{junk_params, junk_values, param_keys, safe_value};
/// Commands that replace/close the active document or touch the filesystem are fine to call, but a
/// few need their paths redirected into a temp dir (handled by `junk_params`).
/// few need their file and folder params redirected into a temp dir (`safe_value`, which
/// `junk_params` applies).
fn safe_path() -> String {
vectorcraft_testkit::temp_dir("sweep").join("out.bin").to_string_lossy().to_string()
}
@@ -230,6 +231,54 @@ fn tool_edit_state(s: &Session) -> ToolEditState {
(docs, s.active_index(), s.paint.clone())
}
/// The params the tool-edit sweep sends `c`: junk params, then each documented key turned on and
/// off, with a string under a file or folder key replaced by a location inside `path`'s folder.
fn tool_edit_cases(c: &vectorcraft_engine::CommandSpec, path: &str) -> Vec<Value> {
let mut cases = junk_params(c.params, path);
if heavy(c.id) {
cases.truncate(12);
}
for k in param_keys(c.params) {
for v in [json!(true), json!(false), json!(1), json!("on")] {
cases.push(json!({ k.as_str(): safe_value(&k, &v, path) }));
}
}
// Find Objects selects only with a filter, which one key at a time does not give it.
if c.id == "document.find" {
cases.extend([json!({"kind": "Path", "select": true}), json!({"name": "no such name", "select": true})]);
}
cases
}
/// Every string the sweeps send under a key that names a file or folder to write is inside the
/// sweep's temp folder, so no run writes into the working directory.
#[test]
fn sweep_cases_name_files_only_inside_the_temp_folder() {
let path = safe_path();
let dir = vectorcraft_testkit::temp_dir("sweep");
let keys = ["path", "diffPath", "folder", "destinationFolder"];
let mut seen = std::collections::BTreeSet::new();
let mut outside = std::collections::BTreeSet::new();
for c in command_specs() {
let mut cases = junk_params(c.params, &path);
cases.extend(tool_edit_cases(c, &path));
for p in &cases {
let Some(o) = p.as_object() else { continue };
for (k, v) in o.iter().filter(|(k, _)| keys.contains(&k.as_str())) {
seen.insert(k.clone());
if let Some(s) = v.as_str()
&& !std::path::Path::new(s).starts_with(&dir)
{
outside.insert(format!("{} {{\"{k}\": {v}}}", c.id));
}
}
}
}
assert_eq!(seen.len(), keys.len(), "the sweeps send only {seen:?}");
let outside: Vec<String> = outside.into_iter().collect();
assert!(outside.is_empty(), "{} cases name a location outside {}:\n{}", outside.len(), dir.display(), outside.join("\n"));
}
/// Every command that runs at once while the active tool computes an edit on another thread (a
/// Shape Builder merge), rather than applying the edit first (`waits_for_tool_edit`), leaves the
/// documents' revisions and selections, the active document and the current paint as they were,
@@ -241,19 +290,7 @@ fn commands_that_do_not_wait_for_a_tool_edit_change_no_document() {
let mut failures = vec![];
for fx in [Fixture::Single, Fixture::Multi] {
for c in command_specs().iter().filter(|c| !vectorcraft_engine::waits_for_tool_edit(c.id)) {
let mut cases = junk_params(c.params, &path);
if heavy(c.id) {
cases.truncate(12);
}
for k in param_keys(c.params) {
for v in [json!(true), json!(false), json!(1), json!("on")] {
cases.push(json!({ k.as_str(): v }));
}
}
// Find Objects selects only with a filter, which one key at a time does not give it.
if c.id == "document.find" {
cases.extend([json!({"kind": "Path", "select": true}), json!({"name": "no such name", "select": true})]);
}
let cases = tool_edit_cases(c, &path);
for p in &cases {
let mut s = fx.session();
let before = tool_edit_state(&s);
+1 -1
View File
@@ -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")),
+3
View File
@@ -173,6 +173,9 @@ pub(crate) struct Source {
pub(crate) text: Rc<[u16]>,
/// Which source this is, as [`crate::Realm::error_source`] gives it.
pub(crate) tag: u32,
/// Writes to read-only and getter-only properties, and new properties on objects that are
/// not extensible, throw in this source's code even where it is not strict mode code.
pub(crate) strict_writes: bool,
}
#[derive(Debug)]
+1 -1
View File
@@ -184,7 +184,7 @@ pub(crate) enum Func {
Native { f: NativeFn, kind: NativeKind, constructor: bool, name: &'static str },
Host { f: Rc<HostFn>, name: Rc<str> },
Data { f: DataFn, name: Rc<str>, read: crate::host::ArgRead },
Eval { f: DataFn, end: Rc<dyn Fn()>, name: Rc<str> },
Eval { f: DataFn, end: Rc<dyn Fn()>, strict_writes: Rc<dyn Fn() -> Option<bool>>, name: Rc<str> },
Bound { target: ObjId, this: Value, args: Rc<[Value]> },
}
+18 -6
View File
@@ -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
View File
@@ -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),
+56 -1
View File
@@ -691,6 +691,61 @@ fn a_strict_realm_runs_scripts_as_strict_mode_code() {
assert_eq!(realm.run("(function () { return typeof this; })()"), Ok(JsValue::String("object".into())));
}
#[test]
fn strict_writes_are_separate_from_es5_strict() {
let setup = "var ro = {}; Object.defineProperty(ro, 'x', {value: 1}); \
var getter = {get g() { return 1; }}; var shut = Object.preventExtensions({a: 1}); var open = {};";
let mut realm = Realm::new();
realm.set_strict_writes(true);
realm.run(setup).unwrap();
for (src, why) in [
("ro.x = 2", "cannot assign to property 'x': it is read-only"),
("ro['x'] += 1", "cannot assign to property 'x': it is read-only"),
("ro.x++", "cannot assign to property 'x': it is read-only"),
("getter.g = 2", "cannot assign to property 'g': it has only a getter"),
("shut.b = 2", "cannot assign to property 'b': the object is not extensible"),
("Object.create(ro).x = 2", "cannot assign to property 'x': it is read-only"),
("NaN = 1", "cannot assign to property 'NaN': it is read-only"),
("(function () { ro.x = 2; })()", "cannot assign to property 'x': it is read-only"),
("eval('ro.x = 2')", "cannot assign to property 'x': it is read-only"),
("(0, eval)('ro.x = 2')", "cannot assign to property 'x': it is read-only"),
("Function('ro.x = 2')()", "cannot assign to property 'x': it is read-only"),
] {
let e = realm.run(src).unwrap_err();
assert_eq!(e.kind, RunErrorKind::Thrown, "{src}");
assert!(e.message.contains("TypeError") && e.message.contains(why), "{src}: {}", e.message);
}
// Writes that can be made, and the rest of strict mode, are as before.
assert_eq!(realm.run("open.n = 1; shut.a = 5; open.n + shut.a"), Ok(JsValue::Number(6.0)));
assert_eq!(realm.run("with ({w: 2}) { w } "), Ok(JsValue::Number(2.0)));
assert_eq!(realm.run("(function (a, a) { return a; })(1, 2)"), Ok(JsValue::Number(2.0)));
assert_eq!(realm.run("010"), Ok(JsValue::Number(8.0)));
assert_eq!(realm.run("undeclared = 3; undeclared"), Ok(JsValue::Number(3.0)));
assert_eq!(realm.run("(function () { return typeof this; })()"), Ok(JsValue::String("object".into())));
assert_eq!(realm.run("delete Object.prototype"), Ok(JsValue::Bool(false)));
assert_eq!(realm.run("'abc'.length = 1; 'abc'.extra = 1; 1"), Ok(JsValue::Number(1.0)), "a primitive's properties as in sloppy mode");
// 'use strict' keeps all of ES5 strict mode.
assert_eq!(realm.run("'use strict'; with ({}) {}").map_err(|e| e.kind), Err(RunErrorKind::Syntax));
assert_eq!(realm.run("'use strict'; delete Object.prototype").map_err(|e| e.kind), Err(RunErrorKind::Thrown));
// Off, the same writes are ignored, as in sloppy mode code.
realm.set_strict_writes(false);
assert_eq!(realm.run("ro.x = 2; getter.g = 2; shut.b = 2; NaN = 1; ro.x + getter.g + String(shut.b)"), Ok(JsValue::String("2undefined".into())));
// A function compiled with the writes on keeps them when called from code without.
let mut realm = Realm::new();
realm.set_strict_writes(true);
realm.run(&format!("{setup} function w() {{ ro.x = 2; }}")).unwrap();
realm.set_strict_writes(false);
assert!(realm.run("w()").unwrap_err().message.contains("it is read-only"));
assert_eq!(realm.run("ro.x = 3; ro.x"), Ok(JsValue::Number(1.0)));
// An undeclared name added to a global object that is not extensible.
let mut realm = Realm::new();
realm.set_strict_writes(true);
let e = realm.run("Object.preventExtensions(this); newName = 1").unwrap_err();
assert!(e.message.contains("cannot assign to property 'newName': the object is not extensible"), "{}", e.message);
let mut realm = Realm::new();
assert_eq!(realm.run("Object.preventExtensions(this); newName = 1; typeof newName"), Ok(JsValue::String("undefined".into())));
}
#[test]
fn an_apply_that_throws_while_reading_its_arguments_keeps_nothing_alive() {
// Each call reads a fresh array at index 0 before index 1 throws; the arrays must not stay
@@ -774,7 +829,7 @@ fn deep_nesting_compiles_on_a_small_stack() {
// ---- the verifier ----
fn code(ops: Vec<Op>) -> Code {
let source = Rc::new(Source { lines: crate::LineTable::new(&[]), text: Rc::from(Vec::<u16>::new()), tag: 0 });
let source = Rc::new(Source { lines: crate::LineTable::new(&[]), text: Rc::from(Vec::<u16>::new()), tag: 0, strict_writes: false });
Code {
positions: vec![0; ops.len()],
ops,
+8
View File
@@ -174,6 +174,14 @@ impl Realm {
self.unbroken_fuel = fuel;
}
/// With `on`, the scripts run next (and the code they define and evaluate) throw a
/// `TypeError` when they assign to a read-only or getter-only property or add a property to
/// an object that is not extensible, as strict mode code does, without the rest of strict
/// mode. Off by default.
pub fn set_strict_writes(&mut self, on: bool) {
self.interp.strict_writes = on;
}
pub fn limits(&self) -> &Limits {
&self.interp.limits
}
+21 -5
View File
@@ -502,10 +502,26 @@ pub fn param_keys(doc: &str) -> Vec<String> {
out
}
/// Parameter-name keys that name filesystem paths: fuzzing these with arbitrary strings could
/// write files anywhere, so callers substitute a temp path.
/// Parameter-name keys that name a file or folder a command may write: fuzzing these with
/// arbitrary strings could write files anywhere, so callers substitute a temp path.
pub fn is_path_key(k: &str) -> bool {
k == "path"
matches!(k, "path" | "diffPath") || is_folder_key(k)
}
/// Path keys that name a folder a command creates or writes files into.
pub fn is_folder_key(k: &str) -> bool {
matches!(k, "folder" | "destinationFolder")
}
/// The location to send under the path key `k`: `safe_path` for a file, a folder next to it for
/// a folder key.
pub fn safe_location(k: &str, safe_path: &str) -> String {
if is_folder_key(k) { format!("{safe_path}-folder") } else { safe_path.to_string() }
}
/// `v`, or the temp location for `k` when `k` is a path key and `v` is a string.
pub fn safe_value(k: &str, v: &Value, safe_path: &str) -> Value {
if is_path_key(k) && v.is_string() { json!(safe_location(k, safe_path)) } else { v.clone() }
}
/// A deterministic list of junk param objects for `doc` (the command's params doc): each
@@ -516,7 +532,7 @@ pub fn junk_params(doc: &str, safe_path: &str) -> Vec<Value> {
let mut out = vec![Value::Null, json!([]), json!("str"), json!(42), json!({"unknownKey": 1})];
for k in &keys {
for (i, v) in junk.iter().enumerate() {
let v = if is_path_key(k) && v.is_string() { json!(safe_path) } else { v.clone() };
let v = safe_value(k, v, safe_path);
// Sparse: one key at a time with every junk value, plus a few combos.
let mut o = serde_json::Map::new();
o.insert(k.clone(), v.clone());
@@ -524,7 +540,7 @@ pub fn junk_params(doc: &str, safe_path: &str) -> Vec<Value> {
if i % 5 == 0 {
let mut all = serde_json::Map::new();
for k2 in &keys {
let v2 = if is_path_key(k2) { json!(safe_path) } else { v.clone() };
let v2 = if is_path_key(k2) { json!(safe_location(k2, safe_path)) } else { v.clone() };
all.insert(k2.clone(), v2);
}
out.push(Value::Object(all));
+39 -1
View File
@@ -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() {
+145
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; 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. |